aboutsummaryrefslogtreecommitdiffstats
path: root/sl0ppij/zellij-server/src/screen.rs
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 19:34:15 +0200
committeruser <user@clank>2026-08-12 19:34:15 +0200
commitd1c14ff0f5c5b55c46d91ad12f4a291041480a99 (patch)
tree9dca9628d228b211a6919c9dd65cd7a9b4fcf6b0 /sl0ppij/zellij-server/src/screen.rs
parentchore: initialize sl0ppi monorepo (diff)
downloadsl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.gz
sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.tar.xz
sl0ppi-d1c14ff0f5c5b55c46d91ad12f4a291041480a99.zip
vendor(sl0ppij): import zellij v0.45.0 tree
Squashed import of upstream zellij at 0e6e4404 ("Support the Kitty Image Protocol (#5428)"), the merge-base our fork was developed against. Upstream's 3311 commits are deliberately NOT replayed here; only our own fork commits are, so the monorepo stays small and `git log sl0ppij/` shows sl0p work, not zellij's entire history. Upstream provenance is tracked in FORKS.md and can always be re-fetched from https://github.com/zellij-org/zellij.
Diffstat (limited to 'sl0ppij/zellij-server/src/screen.rs')
-rw-r--r--sl0ppij/zellij-server/src/screen.rs12110
1 files changed, 12110 insertions, 0 deletions
diff --git a/sl0ppij/zellij-server/src/screen.rs b/sl0ppij/zellij-server/src/screen.rs
new file mode 100644
index 0000000..bec746e
--- /dev/null
+++ b/sl0ppij/zellij-server/src/screen.rs
@@ -0,0 +1,12110 @@
+//! Things related to [`Screen`]s.
+//!
+//! # Tab Identification
+//!
+//! Tabs have two distinct identifiers:
+//!
+//! - **ID** (`tab.id`): Stable, unique identifier that never changes after creation.
+//! Used as BTreeMap key and for internal tracking. Monotonically increasing.
+//!
+//! - **Position** (`tab.position`): Current display order (0-based index in tab bar).
+//! Changes when tabs are moved or closed. Used for user-facing operations.
+//!
+//! # Terminology Convention
+//!
+//! - **"id"**: Always means stable identifier
+//! - **"position"**: Always means 0-based display order
+//! - **"index"**: Synonym for "position" (used in public/plugin APIs)
+//!
+//! Examples:
+//! - `close_tab_by_id(5)` - Closes tab with stable ID 5
+//! - `CloseTabWithIndex(2)` - Closes tab at position 2 (3rd tab visually)
+//! - `get_tab_by_position(0)` - Gets first tab in display order
+//! - `PluginInstruction::NewTab(tab_id, ...)` - Uses ID for async communication
+//!
+//! # Key Data Structures
+//!
+//! - `tabs: BTreeMap<usize, Tab>`: Keyed by tab.id (stable identifier)
+//! - `active_tab_ids: BTreeMap<ClientId, usize>`: Maps clients to active tab ID
+//! - `tab_history: BTreeMap<ClientId, Vec<usize>>`: History of tab IDs per client
+
+use std::cell::RefCell;
+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
+use std::net::{IpAddr, Ipv4Addr};
+use std::path::PathBuf;
+use std::rc::Rc;
+use std::str;
+use std::time::{Duration, Instant};
+
+use crate::route::NotificationEnd;
+
+use log::{debug, warn};
+use zellij_utils::data::{
+ CommandOrPlugin, Direction, EventType, FloatingPaneCoordinates, GetFocusedPaneInfoResponse,
+ HostTerminalThemeMode, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse,
+ ListTabsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest,
+ PaneRenderReport, PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize,
+ ResizeStrategy, SessionInfo, Styling, TabInfo, ThemeHue, WebSharing,
+};
+use zellij_utils::errors::prelude::*;
+use zellij_utils::input::actions::Action;
+use zellij_utils::input::command::RunCommand;
+use zellij_utils::input::config::Config;
+use zellij_utils::input::keybinds::{shortcut_for_action, Keybinds};
+use zellij_utils::input::mouse::{MouseEvent, MouseEventType};
+use zellij_utils::input::options::{
+ Clipboard, MobileLayoutConfiguration, NestedSessionHandling, PaneFrameStyle,
+};
+use zellij_utils::ipc::{ExitReason, ServerToClientMsg};
+use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels};
+use zellij_utils::shared::{clean_string_from_control_and_linebreak, detect_theme_hue};
+use zellij_utils::{
+ consts::{session_info_folder_for_session, ZELLIJ_SOCK_DIR},
+ envs::set_session_name,
+ input::command::TerminalAction,
+ input::layout::{
+ FloatingPaneLayout, Layout, PercentOrFixed, Run, RunPluginOrAlias, SwapFloatingLayout,
+ SwapTiledLayout, TabLayoutInfo, TiledPaneLayout,
+ },
+ position::Position,
+};
+
+use crate::background_jobs::BackgroundJob;
+use crate::os_input_output::ResizeCache;
+use crate::pane_groups::PaneGroups;
+use crate::panes::alacritty_functions::xparse_color;
+use crate::panes::nested_session_modal::GuestModalShortcuts;
+use crate::panes::terminal_character::AnsiCode;
+use crate::panes::terminal_pane::{BRACKETED_PASTE_BEGIN, BRACKETED_PASTE_END};
+use crate::session_layout_metadata::{PaneLayoutMetadata, SessionLayoutMetadata};
+
+use crate::{
+ nested_guest::NestedGuestTracker,
+ output::{HostKittyState, Output},
+ panes::kitty_graphics::{KittyHostSupport, KittyImageStore},
+ panes::sixel::SixelImageStore,
+ panes::PaneId,
+ plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction, PluginRenderAsset},
+ pty::{get_default_shell, ClientTabIndexOrPaneId, PtyInstruction, VteBytes},
+ pty_writer::PtyWriteInstruction,
+ tab::{GuestChoiceIndicator, SuppressedPanes, Tab},
+ thread_bus::Bus,
+ ui::loading_indication::LoadingIndication,
+ ClientId, ServerInstruction,
+};
+use zellij_utils::{
+ data::{Event, InputMode, ModeInfo, Palette, PaletteColor, PluginCapabilities, Style},
+ errors::{ContextType, ScreenContext},
+ input::get_mode_info,
+ ipc::{ClientAttributes, PixelDimensions},
+ nested_session::{self, NestedSessionCapability, NestedSessionMessage},
+};
+
+use crate::mobile_mode::{MobileRenderGate, MobileState, ShadowFocusOutcome, FIT_RESIZE_MAX_ITERS};
+
+/// Parses a namespaced OSC 99 response and extracts the original pane ID
+/// and un-namespaced response bytes.
+///
+/// Input bytes (the termwiz OperatingSystemCommand payload after stripping "99;"):
+/// e.g. b"i=p42.mynotif" or b"i=p42.mynotif:p=close;some_data"
+///
+/// Returns Some((terminal_id, full_osc_bytes)) where full_osc_bytes is
+/// the complete reconstructed OSC 99 sequence with original identifier,
+/// ready to write to the pane's PTY.
+/// Denormalizes a namespaced OSC 99 response.
+///
+/// Parses the namespaced `i=p<N>[r][q].<original_id>` format and returns:
+/// - `pane_id`: the terminal pane that originated the notification
+/// - `app_wants_report`: `r` flag — app originally requested `a=report`
+/// - `is_query`: `q` flag — this was a capability query (`p=?`)
+/// - `restored_response_bytes`: the response with the original `i=` value restored
+pub(crate) fn denormalize_notification_response(
+ payload: &[u8],
+) -> Option<(u32, bool, bool, Vec<u8>)> {
+ let payload_str = str::from_utf8(payload).ok()?;
+
+ // Split into metadata and response payload on first ';'
+ let (metadata, response_payload) = match payload_str.find(';') {
+ Some(idx) => (
+ payload_str.get(..idx).unwrap_or_default(),
+ payload_str.get(idx..).unwrap_or_default(),
+ ),
+ None => (payload_str, ""),
+ };
+
+ // Find the i= key in colon-separated metadata
+ let mut terminal_id = None;
+ let mut app_wants_report = false;
+ let mut is_query = false;
+ let mut restored_parts = Vec::new();
+
+ for kv in metadata.split(':') {
+ if let Some(namespaced_value) = kv.strip_prefix("i=p") {
+ // Parse "p<N>[r][q].<original_id>"
+ if let Some(dot_pos) = namespaced_value.find('.') {
+ let flags_part = namespaced_value.get(..dot_pos).unwrap_or_default();
+ let original_id = namespaced_value.get(dot_pos + 1..).unwrap_or_default();
+ let pane_id_str = flags_part.trim_end_matches(|c| c == 'r' || c == 'q');
+ let flag_chars = flags_part.get(pane_id_str.len()..).unwrap_or_default();
+ if let Ok(pid) = pane_id_str.parse::<u32>() {
+ terminal_id = Some(pid);
+ app_wants_report = flag_chars.contains('r');
+ is_query = flag_chars.contains('q');
+ // Empty original_id means the app never sent an i= key;
+ // don't inject one into the response
+ if !original_id.is_empty() {
+ restored_parts.push(format!("i={}", original_id));
+ }
+ continue;
+ }
+ }
+ }
+ restored_parts.push(kv.to_string());
+ }
+
+ let terminal_id = terminal_id?;
+ let restored_metadata = restored_parts.join(":");
+ let full_response = format!("\x1b]99;{}{}\x1b\\", restored_metadata, response_payload);
+ Some((
+ terminal_id,
+ app_wants_report,
+ is_query,
+ full_response.into_bytes(),
+ ))
+}
+
+/// Get the active tab and call a closure on it
+///
+/// If no active tab can be found, an error is logged instead.
+///
+/// # Parameters
+///
+/// - screen: An instance of `Screen` to operate on
+/// - client_id: The client_id, usually taken from the `ScreenInstruction` that's being processed
+/// - closure: A closure satisfying `|tab: &mut Tab| -> ()` OR `|tab: &mut Tab| -> Result<T>` (see
+/// '?' below)
+/// - ?: A literal "?", to append a `?` to the closure when it returns a `Result` type. This
+/// argument is optional and not needed when the closure returns `()`
+macro_rules! active_tab {
+ ($screen:ident, $client_id:ident, $closure:expr) => {
+ match $screen.get_active_tab_mut($client_id) {
+ Ok(active_tab) => {
+ // This could be made more ergonomic by declaring the type of 'active_tab' in the
+ // closure, known as "Type Ascription". Then we could hint the type here and forego the
+ // "&mut Tab" in all the closures below...
+ // See: https://github.com/rust-lang/rust/issues/23416
+ $closure(active_tab);
+ },
+ Err(err) => Err::<(), _>(err).non_fatal(),
+ };
+ };
+ // Same as above, but with an added `?` for when the close returns a `Result` type.
+ ($screen:ident, $client_id:ident, $closure:expr, ?) => {
+ match $screen.get_active_tab_mut($client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab)?;
+ },
+ Err(err) => Err::<(), _>(err).non_fatal(),
+ };
+ };
+}
+
+macro_rules! active_tab_and_connected_client_id {
+ ($screen:ident, $client_id:ident, $closure:expr) => {
+ match $screen.get_active_tab_mut($client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, $client_id);
+ },
+ Err(_) => {
+ if let Some(client_id) = $screen.get_first_client_id() {
+ match $screen.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, client_id);
+ },
+ Err(err) => Err::<(), _>(err).non_fatal(),
+ }
+ } else {
+ log::error!("No client ids in screen found");
+ };
+ },
+ }
+ };
+ // Same as above, but with an added `?` for when the closure returns a `Result` type.
+ ($screen:ident, $client_id:ident, $closure:expr, ?) => {
+ match $screen.get_active_tab_mut($client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, $client_id).non_fatal();
+ },
+ Err(_) => {
+ if let Some(client_id) = $screen.get_first_client_id() {
+ match $screen.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, client_id)?;
+ },
+ Err(err) => Err::<(), _>(err).non_fatal(),
+ }
+ } else {
+ log::error!("No client ids in screen found");
+ };
+ },
+ }
+ };
+}
+
+macro_rules! active_tab_and_connected_client_id_with_first_tab_fallback {
+ ($screen:ident, $client_id:ident, $closure:expr) => {
+ match $screen.get_active_tab_mut($client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, Some($client_id));
+ },
+ Err(_) => {
+ if let Some(client_id) = $screen.get_first_client_id() {
+ match $screen.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, Some(client_id));
+ },
+ Err(err) => Err::<(), _>(err).non_fatal(),
+ }
+ } else {
+ match $screen.get_indexed_tab_mut(0) {
+ Some(first_tab) => {
+ $closure(first_tab, None);
+ },
+ None => {
+ log::error!("Not tabs found!");
+ },
+ }
+ };
+ },
+ }
+ };
+ // Same as above, but with an added `?` for when the closure returns a `Result` type.
+ ($screen:ident, $client_id:ident, $closure:expr, ?) => {
+ match $screen.get_active_tab_mut($client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, Some($client_id)).non_fatal();
+ },
+ Err(_) => {
+ if let Some(client_id) = $screen.get_first_client_id() {
+ match $screen.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ $closure(active_tab, Some(client_id))?;
+ },
+ Err(err) => Err::<(), _>(err).non_fatal(),
+ }
+ } else {
+ match $screen.get_indexed_tab_mut(0) {
+ Some(first_tab) => {
+ $closure(first_tab, None)?;
+ },
+ None => {
+ log::error!("Not tabs found!");
+ },
+ }
+ };
+ },
+ }
+ };
+}
+
+type InitialTitle = String;
+type HoldForCommand = Option<RunCommand>;
+
+/// Result of overriding a single tab's layout
+#[derive(Debug, Clone)]
+pub struct TabOverrideResult {
+ pub tab_index: usize,
+ pub tab_name: Option<String>,
+ pub tiled_layout: TiledPaneLayout,
+ pub floating_layouts: Vec<FloatingPaneLayout>,
+ pub swap_tiled_layouts: Option<Vec<SwapTiledLayout>>,
+ pub swap_floating_layouts: Option<Vec<SwapFloatingLayout>>,
+ pub new_terminal_pids: Vec<(u32, HoldForCommand)>,
+ pub new_floating_pane_pids: Vec<(u32, HoldForCommand)>,
+ pub plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>,
+}
+
+/// Instructions that can be sent to the [`Screen`].
+#[derive(Debug, Clone)]
+pub enum ScreenInstruction {
+ PtyBytes(u32, VteBytes),
+ PluginBytes(Vec<PluginRenderAsset>),
+ Render,
+ RenderToClients,
+ NewPane(
+ PaneId,
+ Option<InitialTitle>,
+ HoldForCommand,
+ Option<Run>, // invoked with
+ NewPanePlacement,
+ bool, // start suppressed
+ ClientTabIndexOrPaneId,
+ Option<NotificationEnd>, // completion signal
+ bool, // set_blocking
+ ),
+ OpenInPlaceEditor(PaneId, ClientTabIndexOrPaneId),
+ TogglePaneEmbedOrFloating(ClientId, Option<NotificationEnd>),
+ ToggleFloatingPanes(ClientId, Option<TerminalAction>, Option<NotificationEnd>),
+ ShowFloatingPanes {
+ client_id: ClientId,
+ tab_id: Option<usize>,
+ completion: Option<NotificationEnd>,
+ },
+ HideFloatingPanes {
+ client_id: ClientId,
+ tab_id: Option<usize>,
+ completion: Option<NotificationEnd>,
+ },
+ AreFloatingPanesVisible {
+ client_id: ClientId,
+ tab_id: Option<usize>,
+ completion: Option<NotificationEnd>,
+ },
+ WriteCharacter(
+ Option<KeyWithModifier>,
+ Vec<u8>,
+ bool,
+ ClientId,
+ Option<NotificationEnd>,
+ ), // bool ->
+ // is_kitty_keyboard_protocol
+ Resize(ClientId, ResizeStrategy, Option<NotificationEnd>),
+ SwitchFocus(ClientId, Option<NotificationEnd>),
+ FocusNextPane(ClientId, Option<NotificationEnd>),
+ FocusPreviousPane(ClientId, Option<NotificationEnd>),
+ FocusLastPane(ClientId, Option<NotificationEnd>),
+ MoveFocusLeft(ClientId, Option<NotificationEnd>),
+ MoveFocusLeftOrPreviousTab(ClientId, Option<NotificationEnd>),
+ MoveFocusDown(ClientId, Option<NotificationEnd>),
+ MoveFocusUp(ClientId, Option<NotificationEnd>),
+ MoveFocusRight(ClientId, Option<NotificationEnd>),
+ MoveFocusRightOrNextTab(ClientId, Option<NotificationEnd>),
+ MovePane(ClientId, Option<NotificationEnd>),
+ MovePaneBackwards(ClientId, Option<NotificationEnd>),
+ MovePaneUp(ClientId, Option<NotificationEnd>),
+ MovePaneDown(ClientId, Option<NotificationEnd>),
+ MovePaneRight(ClientId, Option<NotificationEnd>),
+ MovePaneLeft(ClientId, Option<NotificationEnd>),
+ Exit,
+ ClearScreen(ClientId, Option<NotificationEnd>),
+ DumpScreen(
+ Option<String>,
+ ClientId,
+ bool,
+ Option<PaneId>,
+ Option<NotificationEnd>,
+ Option<ClientId>, // cli_client_id - used to send output to the CLI client's STDOUT
+ bool, // ansi - preserve ANSI styling in the dump output
+ ),
+ DumpLayout(Option<PathBuf>, ClientId, Option<NotificationEnd>), // PathBuf is the default configured
+ // shell
+ SaveSession(ClientId, Option<NotificationEnd>),
+ DumpLayoutToPlugin {
+ plugin_id: PluginId,
+ tab_index: Option<usize>,
+ response_channel: crossbeam::channel::Sender<DumpSessionLayoutResponse>,
+ },
+ GetFocusedPaneInfo {
+ client_id: ClientId,
+ response_channel: crossbeam::channel::Sender<GetFocusedPaneInfoResponse>,
+ },
+ GetPaneInfo {
+ pane_id: PaneId,
+ response_channel: crossbeam::channel::Sender<Option<PaneInfo>>,
+ },
+ GetTabInfo {
+ tab_id: usize,
+ response_channel: crossbeam::channel::Sender<Option<TabInfo>>,
+ },
+ EditScrollback(ClientId, bool, Option<NotificationEnd>),
+ GetPaneScrollback {
+ pane_id: PaneId,
+ client_id: ClientId,
+ get_full_scrollback: bool,
+ response_channel: crossbeam::channel::Sender<PaneScrollbackResponse>,
+ },
+ ScrollUp(ClientId, Option<NotificationEnd>),
+ ScrollUpAt(Position, ClientId, Option<NotificationEnd>),
+ ScrollDown(ClientId, Option<NotificationEnd>),
+ ScrollDownAt(Position, ClientId, Option<NotificationEnd>),
+ ScrollToBottom(ClientId, Option<NotificationEnd>),
+ ScrollToTop(ClientId, Option<NotificationEnd>),
+ PageScrollUp(ClientId, Option<NotificationEnd>),
+ PageScrollDown(ClientId, Option<NotificationEnd>),
+ HalfPageScrollUp(ClientId, Option<NotificationEnd>),
+ HalfPageScrollDown(ClientId, Option<NotificationEnd>),
+ ClearScroll(ClientId),
+ CloseFocusedPane(ClientId, Option<NotificationEnd>),
+ ToggleActiveTerminalFullscreen(ClientId, Option<NotificationEnd>),
+ ToggleActiveTerminalNoUiFullscreen(ClientId, Option<NotificationEnd>),
+ TogglePaneFrames(Option<NotificationEnd>),
+ SetPaneFrameStyle(PaneFrameStyle, Option<NotificationEnd>),
+ SetSelectable(PaneId, bool),
+ ShowPluginCursor(u32, ClientId, Option<(usize, usize)>),
+ ClosePane(
+ PaneId,
+ Option<ClientId>,
+ Option<NotificationEnd>,
+ Option<i32>,
+ ), // i32 -> optional exit
+ // status
+ HoldPane(PaneId, Option<i32>, RunCommand),
+ UpdatePaneName(Vec<u8>, ClientId, Option<NotificationEnd>),
+ UndoRenamePane(ClientId, Option<NotificationEnd>),
+ NewTab(
+ Option<PathBuf>,
+ Option<TerminalAction>,
+ Option<TiledPaneLayout>,
+ Vec<FloatingPaneLayout>,
+ Option<String>,
+ // `None` falls back to `Screen::default_layout`'s swap layouts.
+ (
+ Option<Vec<SwapTiledLayout>>,
+ Option<Vec<SwapFloatingLayout>>,
+ ),
+ Option<Vec<CommandOrPlugin>>, // initial_panes
+ bool, // block_on_first_terminal
+ bool, // should_change_focus_to_new_tab
+ (ClientId, bool), // bool -> is_web_client
+ Option<NotificationEnd>, // completion signal
+ ),
+ /// Apply layout to tab with given stable ID.
+ ///
+ /// The sixth parameter (usize) is a stable identifier (not position) from the
+ /// NewTab → ApplyLayout async flow that passes IDs between threads.
+ ApplyLayout(
+ TiledPaneLayout,
+ Vec<FloatingPaneLayout>,
+ Vec<(u32, HoldForCommand)>, // new pane pids
+ Vec<(u32, HoldForCommand)>, // new floating pane pids
+ HashMap<RunPluginOrAlias, Vec<u32>>,
+ usize, // tab_id - stable identifier from NewTab instruction
+ bool, // should change focus to new tab
+ (ClientId, bool), // bool -> is_web_client
+ Option<NotificationEnd>, // regular completion signal
+ Option<(u32, NotificationEnd)>, // blocking_terminal (terminal_id, completion_tx)
+ ),
+ SwitchTabNext(ClientId, Option<NotificationEnd>),
+ SwitchTabPrev(ClientId, Option<NotificationEnd>),
+ ToggleActiveSyncTab(ClientId, Option<NotificationEnd>),
+ CloseTab(ClientId, Option<NotificationEnd>),
+ GoToTab(u32, Option<ClientId>, Option<NotificationEnd>), // this Option is a hacky workaround, please do not copy this behaviour
+ GoToTabName(
+ String,
+ Option<TerminalAction>, // default_shell
+ bool,
+ Option<ClientId>,
+ Option<NotificationEnd>,
+ ),
+ ToggleTab(ClientId, Option<NotificationEnd>),
+ UpdateTabName(Vec<u8>, ClientId, Option<NotificationEnd>),
+ UndoRenameTab(ClientId, Option<NotificationEnd>),
+ MoveTabLeft(ClientId, Option<NotificationEnd>),
+ MoveTabRight(ClientId, Option<NotificationEnd>),
+ GoToTabWithId(usize, Option<ClientId>, Option<NotificationEnd>),
+ CloseTabWithId(usize, Option<NotificationEnd>),
+ RenameTabWithId(usize, Vec<u8>, Option<NotificationEnd>),
+ BreakPanesToTabWithId {
+ pane_ids: Vec<PaneId>,
+ tab_id: usize,
+ should_change_focus_to_target_tab: bool,
+ client_id: ClientId,
+ completion_tx: Option<NotificationEnd>,
+ },
+ TerminalResize(Size),
+ RecomputeTabSize(ClientId, Size),
+ TerminalPixelDimensions(PixelDimensions),
+ TerminalBackgroundColor(String),
+ TerminalForegroundColor(String),
+ TerminalColorRegisters(Vec<(usize, String)>),
+ SetKittyGraphicsSupport {
+ client_id: ClientId,
+ supported: bool,
+ },
+ /// A pane's Grid intercepted an app-in-pane whitelisted query; Screen
+ /// assigns a token, queues the forward, and dispatches to the client.
+ /// `query` carries the classified form so Screen can match on it
+ /// directly (for cache-fallback synthesis) without re-parsing bytes.
+ ForwardHostQuery {
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+ },
+ NestedSessionMessageFromPane {
+ pane_id: PaneId,
+ message: NestedSessionMessage,
+ },
+ NestedGuestPingTick {
+ pane_id: PaneId,
+ },
+ NestedSessionMessageFromHost {
+ client_id: ClientId,
+ message: NestedSessionMessage,
+ },
+ GuestModalChoice {
+ client_id: ClientId,
+ pane_id: PaneId,
+ outcome: GuestModalOutcome,
+ },
+ /// The client observed the host's reply to a previously forwarded
+ /// query (closed by the Primary-DA barrier or the 500 ms timeout).
+ /// The reply bytes are written verbatim to the originating pane.
+ ForwardedReplyFromHost {
+ token: u32,
+ reply_bytes: Vec<u8>,
+ },
+ /// Internal: a forwarded reply (or its cache-fallback synthesis,
+ /// or a locally-answered query like `ColorPaletteMode`) is ready
+ /// to be delivered to the originating pane. Routed through the
+ /// owning Tab so that the bytes are written to PTY *and* any
+ /// PTY input that arrived while the pane was forward-paused is
+ /// drained and re-fed through vte in stream order. This preserves
+ /// query/reply ordering even when sync replies (DA1, DSR, DECQRM)
+ /// would otherwise race ahead of an async host round-trip.
+ ResumePaneAfterForward {
+ pane_id: PaneId,
+ reply_bytes: Vec<u8>,
+ },
+ /// The host terminal reported a color-palette theme mode (DSR 997 reply
+ /// to `CSI ? 996 n`, or unsolicited notification while `CSI ? 2031 h`
+ /// is enabled). Triggers auto-theme switch (when configured), the
+ /// `HostTerminalThemeChanged` plugin event, and per-pane DSR forwarding
+ /// for panes that opted in via `CSI ? 2031 h`.
+ HostTerminalThemeChanged(HostTerminalThemeMode),
+ /// Manual theme actions issued via the CLI (e.g. `zellij action set-dark-theme`)
+ /// or a keybinding. They share the same convergence point as
+ /// `HostTerminalThemeChanged`, but additionally surface a CLI-friendly error
+ /// via `NotificationEnd` if `theme_dark` and `theme_light` are not both set
+ /// (the auto-switch gate). "Last one wins": these compete naively with
+ /// terminal-driven notifications via the dedupe in
+ /// `update_host_terminal_theme_mode`.
+ SetDarkTheme(Option<NotificationEnd>),
+ SetLightTheme(Option<NotificationEnd>),
+ ToggleTheme(Option<NotificationEnd>),
+ ChangeMode(
+ InputMode,
+ Option<InputMode>,
+ ClientId,
+ Option<NotificationEnd>,
+ ),
+ ChangeModeForAllClients(InputMode, Option<InputMode>, Option<NotificationEnd>),
+ MouseEvent(MouseEvent, ClientId, Option<NotificationEnd>),
+ Copy(ClientId, Option<NotificationEnd>),
+ AddClient(
+ ClientId,
+ bool, // is_web_client
+ Size, // client viewport size
+ Option<usize>, // tab position to focus
+ Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus
+ ),
+ RemoveClient(ClientId),
+ SuppressRenderUntilMobile(ClientId),
+ MobileSizeSettled(ClientId),
+ ForceMobileUngate(ClientId),
+ UpdateSearch(Vec<u8>, ClientId, Option<NotificationEnd>),
+ SearchDown(ClientId, Option<NotificationEnd>),
+ SearchUp(ClientId, Option<NotificationEnd>),
+ SearchToggleCaseSensitivity(ClientId, Option<NotificationEnd>),
+ SearchToggleWholeWord(ClientId, Option<NotificationEnd>),
+ SearchToggleWrap(ClientId, Option<NotificationEnd>),
+ AddRedPaneFrameColorOverride(Vec<PaneId>, Option<String>), // Option<String> => optional error text
+ ClearPaneFrameColorOverride(Vec<PaneId>),
+ SetTabBellFlash(usize, bool), // tab_id, is_flashing
+ PreviousSwapLayout(ClientId, Option<NotificationEnd>),
+ NextSwapLayout(ClientId, Option<NotificationEnd>),
+ OverrideLayout(
+ Option<PathBuf>, // cwd (applies to all tabs)
+ Option<TerminalAction>, // default_shell (applies to all tabs)
+ Vec<TabLayoutInfo>, // layouts for each tab to override
+ bool, // retain_existing_terminal_panes
+ bool, // retain_existing_plugin_panes
+ bool, // apply_only_to_focused_tab
+ ClientId,
+ Option<NotificationEnd>,
+ ),
+ OverrideLayoutComplete(
+ Vec<TabOverrideResult>, // results for each tab
+ bool, // retain_existing_terminal_panes
+ bool, // retain_existing_plugin_panes
+ ClientId,
+ Option<NotificationEnd>,
+ ),
+ QueryTabNames(ClientId, Option<NotificationEnd>),
+ NewTiledPluginPane(
+ RunPluginOrAlias,
+ Option<String>,
+ bool,
+ Option<PathBuf>,
+ ClientId,
+ Option<NotificationEnd>,
+ Option<usize>, // tab_id
+ bool,
+ ), // Option<String> is
+ // optional pane title, bool is skip cache, Option<PathBuf> is an optional cwd
+ NewFloatingPluginPane(
+ RunPluginOrAlias,
+ Option<String>,
+ bool,
+ Option<PathBuf>,
+ Option<FloatingPaneCoordinates>,
+ ClientId,
+ Option<NotificationEnd>,
+ Option<usize>, // tab_id
+ bool,
+ ), // Option<String> is an
+ // optional pane title, bool
+ // is skip cache, Option<PathBuf> is an optional cwd
+ NewInPlacePluginPane(
+ RunPluginOrAlias,
+ Option<String>,
+ PaneId,
+ bool,
+ bool,
+ ClientId,
+ Option<NotificationEnd>,
+ Option<usize>, // tab_id
+ bool,
+ ), // Option<String> is an
+ // optional pane title, first bool is skip cache, second bool is close_replaced_pane
+ StartOrReloadPluginPane(RunPluginOrAlias, Option<String>, Option<NotificationEnd>),
+ AddPlugin(
+ Option<bool>, // should_float
+ bool, // should be opened in place
+ bool, // close_replaced_pane
+ RunPluginOrAlias,
+ Option<String>, // pane title
+ Option<usize>, // tab index
+ u32, // plugin id
+ Option<PaneId>,
+ Option<PathBuf>, // cwd
+ bool, // start suppressed
+ Option<FloatingPaneCoordinates>,
+ Option<bool>, // should focus plugin
+ Option<ClientId>,
+ Option<NotificationEnd>, // completion signal
+ ),
+ UpdatePluginLoadingStage(u32, LoadingIndication), // u32 - plugin_id
+ StartPluginLoadingIndication(u32, LoadingIndication), // u32 - plugin_id
+ ProgressPluginLoadingOffset(u32), // u32 - plugin id
+ RequestStateUpdateForPlugins,
+ LaunchOrFocusPlugin(
+ RunPluginOrAlias,
+ bool,
+ bool,
+ bool,
+ bool,
+ Option<PaneId>,
+ bool,
+ ClientId,
+ Option<NotificationEnd>,
+ Option<usize>, // tab_id
+ ), // bools are: should_float, move_to_focused_tab, should_open_in_place, close_replaced_pane, Option<PaneId> is the pane id to replace, bool following it is skip_cache
+ LaunchPlugin(
+ RunPluginOrAlias,
+ bool,
+ bool,
+ bool,
+ Option<PaneId>,
+ bool,
+ Option<PathBuf>,
+ ClientId,
+ Option<NotificationEnd>,
+ Option<usize>, // tab_id
+ bool,
+ ), // bools are: should_float, should_open_in_place, close_replaced_pane, Option<PaneId> is the pane id to replace, Option<PathBuf> is an optional cwd, bool after is skip_cache
+ SuppressPane(PaneId, ClientId),
+ UnsuppressPane(PaneId, bool), // bool -> should float if hidden
+ UnsuppressOrExpandPane(PaneId, bool), // bool -> should float if hidden
+ FocusPaneWithId(PaneId, bool, bool, ClientId, Option<NotificationEnd>), // bools:
+ // should_float_if_hidden,
+ // should_be_in_place_if_hidden
+ RenamePane(PaneId, Vec<u8>, Option<NotificationEnd>),
+ RenameActivePane(Vec<u8>, ClientId, Option<NotificationEnd>),
+ RenameTab(usize, Vec<u8>, Option<NotificationEnd>),
+ RequestPluginPermissions(
+ u32, // u32 - plugin_id
+ PluginPermission,
+ ),
+ BreakPane(Option<TerminalAction>, ClientId, Option<NotificationEnd>),
+ BreakPaneRight(ClientId, Option<NotificationEnd>),
+ BreakPaneLeft(ClientId, Option<NotificationEnd>),
+ UpdateSessionInfos(
+ BTreeMap<String, SessionInfo>, // String is the session name
+ BTreeMap<String, Duration>, // resurrectable sessions - <name, created>
+ ),
+ ReplacePane(
+ PaneId,
+ HoldForCommand,
+ Option<InitialTitle>,
+ Option<Run>,
+ bool, // close replaced pane
+ ClientTabIndexOrPaneId,
+ Option<NotificationEnd>, // completion signal
+ ),
+ SerializeLayoutForResurrection,
+ RenameSession(String, ClientId, Option<NotificationEnd>), // String -> new name
+ ListClientsMetadata(Option<PathBuf>, ClientId, Option<NotificationEnd>), // Option<PathBuf> - default shell
+ ListPanes {
+ show_all: bool,
+ response_channel: crossbeam::channel::Sender<ListPanesResponse>,
+ },
+ ListTabs {
+ client_id: ClientId,
+ response_channel: crossbeam::channel::Sender<ListTabsResponse>,
+ },
+ GetCurrentTabInfo {
+ client_id: ClientId,
+ response_channel: crossbeam::channel::Sender<Option<TabInfo>>,
+ },
+ Reconfigure {
+ client_id: ClientId,
+ keybinds: Keybinds,
+ default_mode: InputMode,
+ theme: Styling,
+ /// Resolved styling for `theme_dark`. When both this and
+ /// `host_theme_light` are `Some`, Screen auto-switches the active
+ /// palette in response to host CSI 2031 / DSR 997 notifications.
+ host_theme_dark: Option<Styling>,
+ /// Resolved styling for `theme_light`. See `host_theme_dark`.
+ host_theme_light: Option<Styling>,
+ simplified_ui: bool,
+ default_shell: Option<PathBuf>,
+ pane_frame_style: PaneFrameStyle,
+ copy_command: Option<String>,
+ copy_to_clipboard: Option<Clipboard>,
+ copy_on_select: bool,
+ auto_layout: bool,
+ rounded_corners: bool,
+ hide_session_name: bool,
+ stacked_resize: bool,
+ stacked_pane_list: bool,
+ default_editor: Option<PathBuf>,
+ advanced_mouse_actions: bool,
+ mouse_scroll_resize: bool,
+ mouse_hover_effects: bool,
+ visual_bell: bool,
+ focus_follows_mouse: bool,
+ mouse_click_through: bool,
+ nested_session_handling: NestedSessionHandling,
+ },
+ RerunCommandPane(u32, Option<NotificationEnd>), // u32 - terminal pane id
+ ResizePaneWithId(ResizeStrategy, PaneId),
+ EditScrollbackForPaneWithId(PaneId, Option<NotificationEnd>),
+ WriteToPaneId(Vec<u8>, PaneId, Option<NotificationEnd>),
+ Paste(Vec<u8>, Option<PaneId>, ClientId, Option<NotificationEnd>),
+ SetPaneColor(
+ PaneId,
+ Option<String>,
+ Option<String>,
+ Option<NotificationEnd>,
+ ),
+ WriteKeyToPaneId(
+ Option<KeyWithModifier>,
+ Vec<u8>,
+ bool, // is_kitty_keyboard_protocol
+ PaneId,
+ Option<NotificationEnd>,
+ ),
+ CopyTextToClipboard(String, u32), // String - text to copy, u32 - plugin_id
+ MovePaneWithPaneId(PaneId),
+ MovePaneWithPaneIdInDirection(PaneId, Direction),
+ ClearScreenForPaneId(PaneId),
+ ScrollUpInPaneId(PaneId),
+ ScrollDownInPaneId(PaneId),
+ ScrollToTopInPaneId(PaneId),
+ ScrollToBottomInPaneId(PaneId),
+ PageScrollUpInPaneId(PaneId),
+ PageScrollDownInPaneId(PaneId),
+ TogglePaneIdFullscreen(PaneId),
+ SetTabFit {
+ client_id: ClientId,
+ tab_id: usize,
+ fit: Option<(PaneId, Size)>,
+ },
+ TogglePaneEmbedOrEjectForPaneId(PaneId),
+ CloseTabWithIndex(usize),
+ BreakPanesToNewTab {
+ pane_ids: Vec<PaneId>,
+ default_shell: Option<TerminalAction>,
+ should_change_focus_to_new_tab: bool,
+ new_tab_name: Option<String>,
+ client_id: ClientId,
+ completion_tx: Option<NotificationEnd>,
+ },
+ BreakPanesToTabWithIndex {
+ pane_ids: Vec<PaneId>,
+ tab_index: usize,
+ should_change_focus_to_new_tab: bool,
+ client_id: ClientId,
+ completion_tx: Option<NotificationEnd>,
+ },
+ ListClientsToPlugin(PluginId, ClientId),
+ TogglePanePinned(ClientId, Option<NotificationEnd>),
+ SetFloatingPanePinned(PaneId, bool),
+ StackPanes(Vec<PaneId>, ClientId, Option<NotificationEnd>),
+ ChangeFloatingPanesCoordinates(
+ Vec<(PaneId, FloatingPaneCoordinates)>,
+ Option<NotificationEnd>,
+ ),
+ TogglePaneBorderless(PaneId, Option<NotificationEnd>),
+ SetPaneBorderless(PaneId, bool, Option<NotificationEnd>),
+ AddHighlightPaneFrameColorOverride(Vec<PaneId>, Option<String>), // Option<String> => optional
+ // message
+ GroupAndUngroupPanes(Vec<PaneId>, Vec<PaneId>, bool, ClientId), // panes_to_group, panes_to_ungroup, bool -> for all clients
+ HighlightAndUnhighlightPanes(Vec<PaneId>, Vec<PaneId>, ClientId), // panes_to_highlight, panes_to_unhighlight
+ FloatMultiplePanes(Vec<PaneId>, ClientId),
+ EmbedMultiplePanes(Vec<PaneId>, ClientId),
+ TogglePaneInGroup(ClientId, Option<NotificationEnd>),
+ ToggleGroupMarking(ClientId, Option<NotificationEnd>),
+ SessionSharingStatusChange(bool),
+ SetMouseSelectionSupport(PaneId, bool),
+ InterceptKeyPresses(PluginId, ClientId),
+ ClearKeyPressesIntercepts(ClientId),
+ ReplacePaneWithExistingPane(PaneId, PaneId, bool, Option<NotificationEnd>), // bool -> suppress_replaced_pane
+ AddWatcherClient(ClientId, Size),
+ RemoveWatcherClient(ClientId),
+ SetFollowedClient(ClientId),
+ WatcherTerminalResize(ClientId, Size),
+ ClearMouseHelpText(ClientId),
+ UpdateAvailableLayouts(Vec<LayoutInfo>, Vec<LayoutWithError>),
+ SetPluginRegexHighlights {
+ pane_id: PaneId,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ },
+ ClearPluginHighlights {
+ pane_id: PaneId,
+ 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>,
+ ansi: bool,
+ },
+ NotifyPaneClosedToSubscribers {
+ pane_id: zellij_utils::data::PaneId,
+ },
+ DesktopNotificationResponse(Vec<u8>, ClientId),
+ PluginSubscribedToAnsiPaneContents(bool), // true = at least one plugin needs ANSI content
+ UpdateBackgroundPluginSubscriptions(PluginId, ClientId, HashSet<EventType>),
+ ClearHintTextCache,
+ BroadcastModeUpdate(ModeInfo, Option<ClientId>), // ModeInfo, optional specific client_id (None = all clients)
+ // Pane-targeting CLI variants
+ ScrollUpWithPaneId(PaneId, Option<NotificationEnd>),
+ ScrollDownWithPaneId(PaneId, Option<NotificationEnd>),
+ ScrollToTopWithPaneId(PaneId, Option<NotificationEnd>),
+ ScrollToBottomWithPaneId(PaneId, Option<NotificationEnd>),
+ PageScrollUpWithPaneId(PaneId, Option<NotificationEnd>),
+ PageScrollDownWithPaneId(PaneId, Option<NotificationEnd>),
+ HalfPageScrollUpWithPaneId(PaneId, Option<NotificationEnd>),
+ HalfPageScrollDownWithPaneId(PaneId, Option<NotificationEnd>),
+ ResizeWithPaneId(PaneId, ResizeStrategy, Option<NotificationEnd>),
+ MovePaneWithPaneIdCli(PaneId, Option<Direction>, Option<NotificationEnd>),
+ MovePaneBackwardsWithPaneId(PaneId, Option<NotificationEnd>),
+ ClearScreenWithPaneId(PaneId, Option<NotificationEnd>),
+ EditScrollbackWithPaneId(PaneId, bool, Option<NotificationEnd>),
+ ToggleFullscreenWithPaneId(PaneId, Option<NotificationEnd>),
+ ToggleNoUiFullscreenWithPaneId(PaneId, Option<NotificationEnd>),
+ TogglePaneEmbedOrFloatingWithPaneId(PaneId, Option<NotificationEnd>),
+ CloseFocusWithPaneId(PaneId, Option<NotificationEnd>),
+ RenamePaneWithPaneId(PaneId, Vec<u8>, Option<NotificationEnd>),
+ UndoRenamePaneWithPaneId(PaneId, Option<NotificationEnd>),
+ TogglePanePinnedWithPaneId(PaneId, Option<NotificationEnd>),
+ // Tab-targeting CLI variants
+ UndoRenameTabWithTabId(usize, Option<NotificationEnd>),
+ ToggleActiveSyncTabWithTabId(usize, Option<NotificationEnd>),
+ ToggleFloatingPanesWithTabId(usize, Option<TerminalAction>, Option<NotificationEnd>),
+ PreviousSwapLayoutWithTabId(usize, Option<NotificationEnd>),
+ NextSwapLayoutWithTabId(usize, Option<NotificationEnd>),
+ MoveTabWithTabId(usize, Direction, Option<NotificationEnd>),
+ EnterMobileMode(ClientId, Option<NotificationEnd>),
+ ExitMobileMode(ClientId, Option<NotificationEnd>),
+ ToggleMobileMode(ClientId, Option<NotificationEnd>),
+ ReevaluateMobileMode {
+ client_id: ClientId,
+ new_size: Size,
+ mobile_layout: MobileLayoutConfiguration,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ },
+ SetSoftKeyboard {
+ client_id: ClientId,
+ on: bool,
+ },
+ SetShadowFocus(ClientId, PaneId),
+ FocusHostSession(ClientId, Option<NotificationEnd>),
+ FocusGuestSession(ClientId, Option<NotificationEnd>),
+ ToggleHostFullscreen(ClientId, Option<NotificationEnd>),
+}
+
+impl From<&ScreenInstruction> for ScreenContext {
+ fn from(screen_instruction: &ScreenInstruction) -> Self {
+ match *screen_instruction {
+ ScreenInstruction::PtyBytes(..) => ScreenContext::HandlePtyBytes,
+ ScreenInstruction::PluginBytes(..) => ScreenContext::PluginBytes,
+ ScreenInstruction::Render => ScreenContext::Render,
+ ScreenInstruction::RenderToClients => ScreenContext::RenderToClients,
+ ScreenInstruction::NewPane(..) => ScreenContext::NewPane,
+ ScreenInstruction::OpenInPlaceEditor(..) => ScreenContext::OpenInPlaceEditor,
+ ScreenInstruction::TogglePaneEmbedOrFloating(..) => {
+ ScreenContext::TogglePaneEmbedOrFloating
+ },
+ ScreenInstruction::ToggleFloatingPanes(..) => ScreenContext::ToggleFloatingPanes,
+ ScreenInstruction::ShowFloatingPanes { .. } => ScreenContext::ShowFloatingPanes,
+ ScreenInstruction::HideFloatingPanes { .. } => ScreenContext::HideFloatingPanes,
+ ScreenInstruction::AreFloatingPanesVisible { .. } => {
+ ScreenContext::AreFloatingPanesVisible
+ },
+ ScreenInstruction::WriteCharacter(..) => ScreenContext::WriteCharacter,
+ ScreenInstruction::Resize(.., strategy, _) => match strategy {
+ ResizeStrategy {
+ resize: Resize::Increase,
+ direction,
+ ..
+ } => match direction {
+ Some(Direction::Left) => ScreenContext::ResizeIncreaseLeft,
+ Some(Direction::Down) => ScreenContext::ResizeIncreaseDown,
+ Some(Direction::Up) => ScreenContext::ResizeIncreaseUp,
+ Some(Direction::Right) => ScreenContext::ResizeIncreaseRight,
+ None => ScreenContext::ResizeIncreaseAll,
+ },
+ ResizeStrategy {
+ resize: Resize::Decrease,
+ direction,
+ ..
+ } => match direction {
+ Some(Direction::Left) => ScreenContext::ResizeDecreaseLeft,
+ Some(Direction::Down) => ScreenContext::ResizeDecreaseDown,
+ Some(Direction::Up) => ScreenContext::ResizeDecreaseUp,
+ Some(Direction::Right) => ScreenContext::ResizeDecreaseRight,
+ None => ScreenContext::ResizeDecreaseAll,
+ },
+ },
+ ScreenInstruction::SwitchFocus(..) => ScreenContext::SwitchFocus,
+ ScreenInstruction::FocusNextPane(..) => ScreenContext::FocusNextPane,
+ ScreenInstruction::FocusPreviousPane(..) => ScreenContext::FocusPreviousPane,
+ ScreenInstruction::FocusLastPane(..) => ScreenContext::FocusLastPane,
+ ScreenInstruction::MoveFocusLeft(..) => ScreenContext::MoveFocusLeft,
+ ScreenInstruction::MoveFocusLeftOrPreviousTab(..) => {
+ ScreenContext::MoveFocusLeftOrPreviousTab
+ },
+ ScreenInstruction::MoveFocusDown(..) => ScreenContext::MoveFocusDown,
+ ScreenInstruction::MoveFocusUp(..) => ScreenContext::MoveFocusUp,
+ ScreenInstruction::MoveFocusRight(..) => ScreenContext::MoveFocusRight,
+ ScreenInstruction::MoveFocusRightOrNextTab(..) => {
+ ScreenContext::MoveFocusRightOrNextTab
+ },
+ ScreenInstruction::MovePane(..) => ScreenContext::MovePane,
+ ScreenInstruction::MovePaneBackwards(..) => ScreenContext::MovePaneBackwards,
+ ScreenInstruction::MovePaneDown(..) => ScreenContext::MovePaneDown,
+ ScreenInstruction::MovePaneUp(..) => ScreenContext::MovePaneUp,
+ ScreenInstruction::MovePaneRight(..) => ScreenContext::MovePaneRight,
+ ScreenInstruction::MovePaneLeft(..) => ScreenContext::MovePaneLeft,
+ ScreenInstruction::Exit => ScreenContext::Exit,
+ ScreenInstruction::ClearScreen(..) => ScreenContext::ClearScreen,
+ ScreenInstruction::DumpScreen(..) => ScreenContext::DumpScreen,
+ ScreenInstruction::DumpLayout(..) => ScreenContext::DumpLayout,
+ ScreenInstruction::SaveSession(..) => ScreenContext::SaveSession,
+ ScreenInstruction::DumpLayoutToPlugin { .. } => ScreenContext::DumpLayoutToPlugin,
+ ScreenInstruction::GetFocusedPaneInfo { .. } => ScreenContext::GetFocusedPaneInfo,
+ ScreenInstruction::GetPaneInfo { .. } => ScreenContext::GetPaneInfo,
+ ScreenInstruction::GetTabInfo { .. } => ScreenContext::GetTabInfo,
+ ScreenInstruction::EditScrollback(..) => ScreenContext::EditScrollback,
+ ScreenInstruction::GetPaneScrollback { .. } => ScreenContext::GetPaneScrollback,
+ ScreenInstruction::ScrollUp(..) => ScreenContext::ScrollUp,
+ ScreenInstruction::ScrollDown(..) => ScreenContext::ScrollDown,
+ ScreenInstruction::ScrollToBottom(..) => ScreenContext::ScrollToBottom,
+ ScreenInstruction::ScrollToTop(..) => ScreenContext::ScrollToTop,
+ ScreenInstruction::PageScrollUp(..) => ScreenContext::PageScrollUp,
+ ScreenInstruction::PageScrollDown(..) => ScreenContext::PageScrollDown,
+ ScreenInstruction::HalfPageScrollUp(..) => ScreenContext::HalfPageScrollUp,
+ ScreenInstruction::HalfPageScrollDown(..) => ScreenContext::HalfPageScrollDown,
+ ScreenInstruction::ClearScroll(..) => ScreenContext::ClearScroll,
+ ScreenInstruction::CloseFocusedPane(..) => ScreenContext::CloseFocusedPane,
+ ScreenInstruction::ToggleActiveTerminalFullscreen(..) => {
+ ScreenContext::ToggleActiveTerminalFullscreen
+ },
+ ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(..) => {
+ ScreenContext::ToggleActiveTerminalNoUiFullscreen
+ },
+ ScreenInstruction::TogglePaneFrames(..) => ScreenContext::TogglePaneFrames,
+ ScreenInstruction::SetPaneFrameStyle(..) => ScreenContext::SetPaneFrameStyle,
+ ScreenInstruction::SetSelectable(..) => ScreenContext::SetSelectable,
+ ScreenInstruction::ShowPluginCursor(..) => ScreenContext::ShowPluginCursor,
+ ScreenInstruction::ClosePane(..) => ScreenContext::ClosePane,
+ ScreenInstruction::HoldPane(..) => ScreenContext::HoldPane,
+ ScreenInstruction::UpdatePaneName(..) => ScreenContext::UpdatePaneName,
+ ScreenInstruction::UndoRenamePane(..) => ScreenContext::UndoRenamePane,
+ ScreenInstruction::NewTab(..) => ScreenContext::NewTab,
+ ScreenInstruction::ApplyLayout(..) => ScreenContext::ApplyLayout,
+ ScreenInstruction::SwitchTabNext(..) => ScreenContext::SwitchTabNext,
+ ScreenInstruction::SwitchTabPrev(..) => ScreenContext::SwitchTabPrev,
+ ScreenInstruction::CloseTab(..) => ScreenContext::CloseTab,
+ ScreenInstruction::GoToTab(..) => ScreenContext::GoToTab,
+ ScreenInstruction::GoToTabName(..) => ScreenContext::GoToTabName,
+ ScreenInstruction::UpdateTabName(..) => ScreenContext::UpdateTabName,
+ ScreenInstruction::UndoRenameTab(..) => ScreenContext::UndoRenameTab,
+ ScreenInstruction::MoveTabLeft(..) => ScreenContext::MoveTabLeft,
+ ScreenInstruction::MoveTabRight(..) => ScreenContext::MoveTabRight,
+ ScreenInstruction::GoToTabWithId(..) => ScreenContext::GoToTabWithId,
+ ScreenInstruction::CloseTabWithId(..) => ScreenContext::CloseTabWithId,
+ ScreenInstruction::RenameTabWithId(..) => ScreenContext::RenameTabWithId,
+ ScreenInstruction::BreakPanesToTabWithId { .. } => ScreenContext::BreakPanesToTabWithId,
+ ScreenInstruction::TerminalResize(..) => ScreenContext::TerminalResize,
+ ScreenInstruction::RecomputeTabSize(..) => ScreenContext::RecomputeTabSize,
+ ScreenInstruction::TerminalPixelDimensions(..) => {
+ ScreenContext::TerminalPixelDimensions
+ },
+ ScreenInstruction::TerminalBackgroundColor(..) => {
+ ScreenContext::TerminalBackgroundColor
+ },
+ ScreenInstruction::TerminalForegroundColor(..) => {
+ ScreenContext::TerminalForegroundColor
+ },
+ ScreenInstruction::TerminalColorRegisters(..) => ScreenContext::TerminalColorRegisters,
+ ScreenInstruction::SetKittyGraphicsSupport { .. } => {
+ ScreenContext::SetKittyGraphicsSupport
+ },
+ ScreenInstruction::ForwardHostQuery { .. } => ScreenContext::ForwardHostQuery,
+ ScreenInstruction::NestedSessionMessageFromPane { .. } => {
+ ScreenContext::NestedSessionMessageFromPane
+ },
+ ScreenInstruction::NestedGuestPingTick { .. } => ScreenContext::NestedGuestPingTick,
+ ScreenInstruction::NestedSessionMessageFromHost { .. } => {
+ ScreenContext::NestedSessionMessageFromHost
+ },
+ ScreenInstruction::GuestModalChoice { .. } => ScreenContext::GuestModalChoice,
+ ScreenInstruction::ForwardedReplyFromHost { .. } => {
+ ScreenContext::ForwardedReplyFromHost
+ },
+ ScreenInstruction::ResumePaneAfterForward { .. } => {
+ ScreenContext::ResumePaneAfterForward
+ },
+ ScreenInstruction::HostTerminalThemeChanged(..) => {
+ ScreenContext::HostTerminalThemeChanged
+ },
+ ScreenInstruction::SetDarkTheme(..) => ScreenContext::SetDarkTheme,
+ ScreenInstruction::SetLightTheme(..) => ScreenContext::SetLightTheme,
+ ScreenInstruction::ToggleTheme(..) => ScreenContext::ToggleTheme,
+ ScreenInstruction::ChangeMode(..) => ScreenContext::ChangeMode,
+ ScreenInstruction::ChangeModeForAllClients(..) => {
+ ScreenContext::ChangeModeForAllClients
+ },
+ ScreenInstruction::ToggleActiveSyncTab(..) => ScreenContext::ToggleActiveSyncTab,
+ ScreenInstruction::ScrollUpAt(..) => ScreenContext::ScrollUpAt,
+ ScreenInstruction::ScrollDownAt(..) => ScreenContext::ScrollDownAt,
+ ScreenInstruction::MouseEvent(..) => ScreenContext::MouseEvent,
+ ScreenInstruction::Copy(..) => ScreenContext::Copy,
+ ScreenInstruction::ToggleTab(..) => ScreenContext::ToggleTab,
+ ScreenInstruction::AddClient(..) => ScreenContext::AddClient,
+ ScreenInstruction::RemoveClient(..) => ScreenContext::RemoveClient,
+ ScreenInstruction::SuppressRenderUntilMobile(..) => {
+ ScreenContext::SuppressRenderUntilMobile
+ },
+ ScreenInstruction::MobileSizeSettled(..) => ScreenContext::MobileSizeSettled,
+ ScreenInstruction::ForceMobileUngate(..) => ScreenContext::ForceMobileUngate,
+ ScreenInstruction::UpdateSearch(..) => ScreenContext::UpdateSearch,
+ ScreenInstruction::SearchDown(..) => ScreenContext::SearchDown,
+ ScreenInstruction::SearchUp(..) => ScreenContext::SearchUp,
+ ScreenInstruction::SearchToggleCaseSensitivity(..) => {
+ ScreenContext::SearchToggleCaseSensitivity
+ },
+ ScreenInstruction::SearchToggleWholeWord(..) => ScreenContext::SearchToggleWholeWord,
+ ScreenInstruction::SearchToggleWrap(..) => ScreenContext::SearchToggleWrap,
+ ScreenInstruction::AddRedPaneFrameColorOverride(..) => {
+ ScreenContext::AddRedPaneFrameColorOverride
+ },
+ ScreenInstruction::ClearPaneFrameColorOverride(..) => {
+ ScreenContext::ClearPaneFrameColorOverride
+ },
+ ScreenInstruction::SetTabBellFlash(..) => ScreenContext::SetTabBellFlash,
+ ScreenInstruction::PreviousSwapLayout(..) => ScreenContext::PreviousSwapLayout,
+ ScreenInstruction::NextSwapLayout(..) => ScreenContext::NextSwapLayout,
+ ScreenInstruction::OverrideLayout(..) => ScreenContext::OverrideLayout,
+ ScreenInstruction::OverrideLayoutComplete(..) => ScreenContext::OverrideLayoutComplete,
+ ScreenInstruction::QueryTabNames(..) => ScreenContext::QueryTabNames,
+ ScreenInstruction::NewTiledPluginPane(..) => ScreenContext::NewTiledPluginPane,
+ ScreenInstruction::NewFloatingPluginPane(..) => ScreenContext::NewFloatingPluginPane,
+ ScreenInstruction::StartOrReloadPluginPane(..) => {
+ ScreenContext::StartOrReloadPluginPane
+ },
+ ScreenInstruction::AddPlugin(..) => ScreenContext::AddPlugin,
+ ScreenInstruction::UpdatePluginLoadingStage(..) => {
+ ScreenContext::UpdatePluginLoadingStage
+ },
+ ScreenInstruction::ProgressPluginLoadingOffset(..) => {
+ ScreenContext::ProgressPluginLoadingOffset
+ },
+ ScreenInstruction::StartPluginLoadingIndication(..) => {
+ ScreenContext::StartPluginLoadingIndication
+ },
+ ScreenInstruction::RequestStateUpdateForPlugins => {
+ ScreenContext::RequestStateUpdateForPlugins
+ },
+ ScreenInstruction::LaunchOrFocusPlugin(..) => ScreenContext::LaunchOrFocusPlugin,
+ ScreenInstruction::LaunchPlugin(..) => ScreenContext::LaunchPlugin,
+ ScreenInstruction::SuppressPane(..) => ScreenContext::SuppressPane,
+ ScreenInstruction::UnsuppressPane(..) => ScreenContext::UnsuppressPane,
+ ScreenInstruction::UnsuppressOrExpandPane(..) => ScreenContext::UnsuppressOrExpandPane,
+ ScreenInstruction::FocusPaneWithId(..) => ScreenContext::FocusPaneWithId,
+ ScreenInstruction::RenamePane(..) => ScreenContext::RenamePane,
+ ScreenInstruction::RenameActivePane(..) => ScreenContext::RenameActivePane,
+ ScreenInstruction::RenameTab(..) => ScreenContext::RenameTab,
+ ScreenInstruction::RequestPluginPermissions(..) => {
+ ScreenContext::RequestPluginPermissions
+ },
+ ScreenInstruction::BreakPane(..) => ScreenContext::BreakPane,
+ ScreenInstruction::BreakPaneRight(..) => ScreenContext::BreakPaneRight,
+ ScreenInstruction::BreakPaneLeft(..) => ScreenContext::BreakPaneLeft,
+ ScreenInstruction::UpdateSessionInfos(..) => ScreenContext::UpdateSessionInfos,
+ ScreenInstruction::ReplacePane(..) => ScreenContext::ReplacePane,
+ ScreenInstruction::NewInPlacePluginPane(..) => ScreenContext::NewInPlacePluginPane,
+ ScreenInstruction::SerializeLayoutForResurrection => {
+ ScreenContext::SerializeLayoutForResurrection
+ },
+ ScreenInstruction::RenameSession(..) => ScreenContext::RenameSession,
+ ScreenInstruction::ListClientsMetadata(..) => ScreenContext::ListClientsMetadata,
+ ScreenInstruction::ListPanes { .. } => ScreenContext::ListPanes,
+ ScreenInstruction::ListTabs { .. } => ScreenContext::ListTabs,
+ ScreenInstruction::GetCurrentTabInfo { .. } => ScreenContext::GetCurrentTabInfo,
+ ScreenInstruction::Reconfigure { .. } => ScreenContext::Reconfigure,
+ ScreenInstruction::RerunCommandPane { .. } => ScreenContext::RerunCommandPane,
+ ScreenInstruction::ResizePaneWithId(..) => ScreenContext::ResizePaneWithId,
+ ScreenInstruction::EditScrollbackForPaneWithId(..) => {
+ ScreenContext::EditScrollbackForPaneWithId
+ },
+ ScreenInstruction::WriteToPaneId(..) => ScreenContext::WriteToPaneId,
+ ScreenInstruction::Paste(..) => ScreenContext::Paste,
+ ScreenInstruction::SetPaneColor(..) => ScreenContext::SetPaneColor,
+ ScreenInstruction::WriteKeyToPaneId(..) => ScreenContext::WriteKeyToPaneId,
+ ScreenInstruction::CopyTextToClipboard(..) => ScreenContext::CopyTextToClipboard,
+ ScreenInstruction::MovePaneWithPaneId(..) => ScreenContext::MovePaneWithPaneId,
+ ScreenInstruction::MovePaneWithPaneIdInDirection(..) => {
+ ScreenContext::MovePaneWithPaneIdInDirection
+ },
+ ScreenInstruction::ClearScreenForPaneId(..) => ScreenContext::ClearScreenForPaneId,
+ ScreenInstruction::ScrollUpInPaneId(..) => ScreenContext::ScrollUpInPaneId,
+ ScreenInstruction::ScrollDownInPaneId(..) => ScreenContext::ScrollDownInPaneId,
+ ScreenInstruction::ScrollToTopInPaneId(..) => ScreenContext::ScrollToTopInPaneId,
+ ScreenInstruction::ScrollToBottomInPaneId(..) => ScreenContext::ScrollToBottomInPaneId,
+ ScreenInstruction::PageScrollUpInPaneId(..) => ScreenContext::PageScrollUpInPaneId,
+ ScreenInstruction::PageScrollDownInPaneId(..) => ScreenContext::PageScrollDownInPaneId,
+ ScreenInstruction::TogglePaneIdFullscreen(..) => ScreenContext::TogglePaneIdFullscreen,
+ ScreenInstruction::SetTabFit { .. } => ScreenContext::SetTabFit,
+ ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(..) => {
+ ScreenContext::TogglePaneEmbedOrEjectForPaneId
+ },
+ ScreenInstruction::CloseTabWithIndex(..) => ScreenContext::CloseTabWithIndex,
+ ScreenInstruction::BreakPanesToNewTab { .. } => ScreenContext::BreakPanesToNewTab,
+ ScreenInstruction::BreakPanesToTabWithIndex { .. } => {
+ ScreenContext::BreakPanesToTabWithIndex
+ },
+ ScreenInstruction::ListClientsToPlugin(..) => ScreenContext::ListClientsToPlugin,
+ ScreenInstruction::TogglePanePinned(..) => ScreenContext::TogglePanePinned,
+ ScreenInstruction::SetFloatingPanePinned(..) => ScreenContext::SetFloatingPanePinned,
+ ScreenInstruction::StackPanes(..) => ScreenContext::StackPanes,
+ ScreenInstruction::ChangeFloatingPanesCoordinates(..) => {
+ ScreenContext::ChangeFloatingPanesCoordinates
+ },
+ ScreenInstruction::TogglePaneBorderless(..) => ScreenContext::TogglePaneBorderless,
+ ScreenInstruction::SetPaneBorderless(..) => ScreenContext::SetPaneBorderless,
+ ScreenInstruction::AddHighlightPaneFrameColorOverride(..) => {
+ ScreenContext::AddHighlightPaneFrameColorOverride
+ },
+ ScreenInstruction::GroupAndUngroupPanes(..) => ScreenContext::GroupAndUngroupPanes,
+ ScreenInstruction::HighlightAndUnhighlightPanes(..) => {
+ ScreenContext::HighlightAndUnhighlightPanes
+ },
+ ScreenInstruction::FloatMultiplePanes(..) => ScreenContext::FloatMultiplePanes,
+ ScreenInstruction::EmbedMultiplePanes(..) => ScreenContext::EmbedMultiplePanes,
+ ScreenInstruction::TogglePaneInGroup(..) => ScreenContext::TogglePaneInGroup,
+ ScreenInstruction::ToggleGroupMarking(..) => ScreenContext::ToggleGroupMarking,
+ ScreenInstruction::SessionSharingStatusChange(..) => {
+ ScreenContext::SessionSharingStatusChange
+ },
+ ScreenInstruction::SetMouseSelectionSupport(..) => {
+ ScreenContext::SetMouseSelectionSupport
+ },
+ ScreenInstruction::InterceptKeyPresses(..) => ScreenContext::InterceptKeyPresses,
+ ScreenInstruction::ClearKeyPressesIntercepts(..) => {
+ ScreenContext::ClearKeyPressesIntercepts
+ },
+ ScreenInstruction::ReplacePaneWithExistingPane(..) => {
+ ScreenContext::ReplacePaneWithExistingPane
+ },
+ ScreenInstruction::AddWatcherClient(..) => ScreenContext::AddWatcherClient,
+ ScreenInstruction::RemoveWatcherClient(..) => ScreenContext::RemoveWatcherClient,
+ ScreenInstruction::SetFollowedClient(..) => ScreenContext::SetFollowedClient,
+ ScreenInstruction::WatcherTerminalResize(..) => ScreenContext::WatcherTerminalResize,
+ ScreenInstruction::ClearMouseHelpText(..) => ScreenContext::ClearMouseHelpText,
+ ScreenInstruction::UpdateAvailableLayouts(..) => ScreenContext::UpdateAvailableLayouts,
+ ScreenInstruction::SetPluginRegexHighlights { .. } => {
+ ScreenContext::SetPluginRegexHighlights
+ },
+ ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights,
+ ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights,
+ ScreenInstruction::DesktopNotificationResponse(..) => {
+ ScreenContext::DesktopNotificationResponse
+ },
+ ScreenInstruction::SubscribeToPaneRenders { .. } => {
+ ScreenContext::SubscribeToPaneRenders
+ },
+ ScreenInstruction::NotifyPaneClosedToSubscribers { .. } => {
+ ScreenContext::NotifyPaneClosedToSubscribers
+ },
+ ScreenInstruction::PluginSubscribedToAnsiPaneContents(..) => {
+ ScreenContext::PluginSubscribedToAnsiPaneContents
+ },
+ ScreenInstruction::UpdateBackgroundPluginSubscriptions(..) => {
+ ScreenContext::UpdateBackgroundPluginSubscriptions
+ },
+ ScreenInstruction::ClearHintTextCache => ScreenContext::ClearHintTextCache,
+ ScreenInstruction::BroadcastModeUpdate(..) => ScreenContext::BroadcastModeUpdate,
+ // Pane-targeting CLI variants
+ ScreenInstruction::ScrollUpWithPaneId(..) => ScreenContext::ScrollUpWithPaneId,
+ ScreenInstruction::ScrollDownWithPaneId(..) => ScreenContext::ScrollDownWithPaneId,
+ ScreenInstruction::ScrollToTopWithPaneId(..) => ScreenContext::ScrollToTopWithPaneId,
+ ScreenInstruction::ScrollToBottomWithPaneId(..) => {
+ ScreenContext::ScrollToBottomWithPaneId
+ },
+ ScreenInstruction::PageScrollUpWithPaneId(..) => ScreenContext::PageScrollUpWithPaneId,
+ ScreenInstruction::PageScrollDownWithPaneId(..) => {
+ ScreenContext::PageScrollDownWithPaneId
+ },
+ ScreenInstruction::HalfPageScrollUpWithPaneId(..) => {
+ ScreenContext::HalfPageScrollUpWithPaneId
+ },
+ ScreenInstruction::HalfPageScrollDownWithPaneId(..) => {
+ ScreenContext::HalfPageScrollDownWithPaneId
+ },
+ ScreenInstruction::ResizeWithPaneId(..) => ScreenContext::ResizeWithPaneId,
+ ScreenInstruction::MovePaneWithPaneIdCli(..) => ScreenContext::MovePaneWithPaneIdCli,
+ ScreenInstruction::MovePaneBackwardsWithPaneId(..) => {
+ ScreenContext::MovePaneBackwardsWithPaneId
+ },
+ ScreenInstruction::ClearScreenWithPaneId(..) => ScreenContext::ClearScreenWithPaneId,
+ ScreenInstruction::EditScrollbackWithPaneId(..) => {
+ ScreenContext::EditScrollbackWithPaneId
+ },
+ ScreenInstruction::ToggleFullscreenWithPaneId(..) => {
+ ScreenContext::ToggleFullscreenWithPaneId
+ },
+ ScreenInstruction::ToggleNoUiFullscreenWithPaneId(..) => {
+ ScreenContext::ToggleNoUiFullscreenWithPaneId
+ },
+ ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(..) => {
+ ScreenContext::TogglePaneEmbedOrFloatingWithPaneId
+ },
+ ScreenInstruction::CloseFocusWithPaneId(..) => ScreenContext::CloseFocusWithPaneId,
+ ScreenInstruction::RenamePaneWithPaneId(..) => ScreenContext::RenamePaneWithPaneId,
+ ScreenInstruction::UndoRenamePaneWithPaneId(..) => {
+ ScreenContext::UndoRenamePaneWithPaneId
+ },
+ ScreenInstruction::TogglePanePinnedWithPaneId(..) => {
+ ScreenContext::TogglePanePinnedWithPaneId
+ },
+ // Tab-targeting CLI variants
+ ScreenInstruction::UndoRenameTabWithTabId(..) => ScreenContext::UndoRenameTabWithTabId,
+ ScreenInstruction::ToggleActiveSyncTabWithTabId(..) => {
+ ScreenContext::ToggleActiveSyncTabWithTabId
+ },
+ ScreenInstruction::ToggleFloatingPanesWithTabId(..) => {
+ ScreenContext::ToggleFloatingPanesWithTabId
+ },
+ ScreenInstruction::PreviousSwapLayoutWithTabId(..) => {
+ ScreenContext::PreviousSwapLayoutWithTabId
+ },
+ ScreenInstruction::NextSwapLayoutWithTabId(..) => {
+ ScreenContext::NextSwapLayoutWithTabId
+ },
+ ScreenInstruction::MoveTabWithTabId(..) => ScreenContext::MoveTabWithTabId,
+ ScreenInstruction::EnterMobileMode(..) => ScreenContext::EnterMobileMode,
+ ScreenInstruction::ExitMobileMode(..) => ScreenContext::ExitMobileMode,
+ ScreenInstruction::ToggleMobileMode(..) => ScreenContext::ToggleMobileMode,
+ ScreenInstruction::ReevaluateMobileMode { .. } => ScreenContext::ReevaluateMobileMode,
+ ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard,
+ ScreenInstruction::SetShadowFocus(..) => ScreenContext::SetShadowFocus,
+ ScreenInstruction::FocusHostSession(..) => ScreenContext::FocusHostSession,
+ ScreenInstruction::FocusGuestSession(..) => ScreenContext::FocusGuestSession,
+ ScreenInstruction::ToggleHostFullscreen(..) => ScreenContext::ToggleHostFullscreen,
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct CopyOptions {
+ pub command: Option<String>,
+ pub clipboard: Clipboard,
+ pub copy_on_select: bool,
+}
+
+impl CopyOptions {
+ pub(crate) fn new(
+ copy_command: Option<String>,
+ copy_clipboard: Clipboard,
+ copy_on_select: bool,
+ ) -> Self {
+ Self {
+ command: copy_command,
+ clipboard: copy_clipboard,
+ copy_on_select,
+ }
+ }
+
+ #[cfg(test)]
+ pub(crate) fn default() -> Self {
+ Self {
+ command: None,
+ clipboard: Clipboard::default(),
+ copy_on_select: true,
+ }
+ }
+}
+
+// We use this to delay rendering when a new tab opens so that we make sure all plugins
+// (representing portions of the UI) have been fully loaded before the tab is first rendered (with
+// a sensible timeout of 100ms)
+#[derive(Debug, Clone)]
+pub struct RenderBlocker {
+ blocking_plugins: HashMap<u32, Instant>,
+ #[cfg_attr(test, allow(dead_code))]
+ timeout_ms: u64,
+}
+
+impl RenderBlocker {
+ pub fn new(timeout_ms: u64) -> Self {
+ Self {
+ blocking_plugins: HashMap::new(),
+ timeout_ms,
+ }
+ }
+
+ pub fn register_blocking_plugin(&mut self, plugin_id: u32) {
+ self.blocking_plugins.insert(plugin_id, Instant::now());
+ }
+
+ pub fn remove_blocking_plugin(&mut self, plugin_id: u32) {
+ self.blocking_plugins.remove(&plugin_id);
+ }
+
+ #[cfg(test)]
+ pub fn can_render(&mut self) -> bool {
+ // we want the tests to be more deterministic and so we always render without any
+ // optimizations
+ true
+ }
+
+ #[cfg(not(test))]
+ pub fn can_render(&mut self) -> bool {
+ let ret = if self.blocking_plugins.is_empty() {
+ true
+ } else {
+ let timeout = Duration::from_millis(self.timeout_ms);
+ let now = Instant::now();
+
+ self.blocking_plugins
+ .values()
+ .all(|&registered_at| now.duration_since(registered_at) >= timeout)
+ };
+ if ret {
+ self.blocking_plugins.clear();
+ }
+ ret
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct WatcherState {
+ size: Size,
+ should_force_render: bool,
+}
+
+impl WatcherState {
+ pub fn new(size: Size) -> Self {
+ WatcherState {
+ size,
+ should_force_render: true,
+ }
+ }
+
+ pub fn size(&self) -> Size {
+ self.size
+ }
+
+ pub fn set_size(&mut self, size: Size) {
+ self.size = size;
+ }
+
+ pub fn should_force_render(&self) -> bool {
+ self.should_force_render
+ }
+
+ pub fn clear_force_render(&mut self) {
+ self.should_force_render = false;
+ }
+
+ pub fn set_force_render(&mut self) {
+ self.should_force_render = true;
+ }
+}
+
+struct PaneRenderSubscription {
+ pane_ids: HashSet<zellij_utils::data::PaneId>,
+ previous_viewports: HashMap<zellij_utils::data::PaneId, Vec<String>>,
+ ansi: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum NestedGuestChoice {
+ Zoom,
+ Descend,
+ Dismissed,
+}
+
+impl NestedGuestChoice {
+ fn enters_passthrough(&self) -> bool {
+ matches!(self, NestedGuestChoice::Zoom | NestedGuestChoice::Descend)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GuestModalOutcome {
+ Zoom,
+ Descend,
+}
+
+fn format_guest_modal_shortcut(keys: &[KeyWithModifier]) -> Vec<String> {
+ keys.iter().map(|key| key.to_string()).collect()
+}
+
+/// 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 {
+ /// A Bus for sending and receiving messages with the other threads.
+ pub bus: Bus<ScreenInstruction>,
+ /// An optional maximal amount of panes allowed per [`Tab`] in this [`Screen`] instance.
+ max_panes: Option<usize>,
+ /// A map between this [`Screen`]'s tabs and their ID/key.
+ tabs: BTreeMap<usize, Tab>,
+ last_single_pane_tab_names: HashMap<usize, Option<String>>,
+ /// The full size of this [`Screen`].
+ size: Size,
+ pixel_dimensions: PixelDimensions,
+ character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
+ stacked_resize: Rc<RefCell<bool>>,
+ stacked_pane_list: Rc<RefCell<bool>>,
+ sixel_image_store: Rc<RefCell<SixelImageStore>>,
+ kitty_image_store: Rc<RefCell<KittyImageStore>>,
+ kitty_host_capabilities: Rc<RefCell<HashMap<ClientId, bool>>>,
+ client_kitty_host_state: Rc<RefCell<HashMap<ClientId, HostKittyState>>>,
+ terminal_emulator_colors: Rc<RefCell<Palette>>,
+ terminal_emulator_color_codes: Rc<RefCell<HashMap<usize, String>>>,
+ connected_clients: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
+ /// The indices of this [`Screen`]'s active [`Tab`]s.
+ active_tab_ids: BTreeMap<ClientId, usize>,
+ client_sizes: HashMap<ClientId, Size>,
+ global_last_active_tab_id: usize,
+ tab_history: BTreeMap<ClientId, Vec<usize>>,
+ pane_history: BTreeMap<ClientId, Vec<PaneId>>,
+ mode_info: BTreeMap<ClientId, ModeInfo>,
+ default_mode_info: ModeInfo, // TODO: restructure ModeInfo to prevent this duplication
+ style: Style,
+ pane_frame_style: PaneFrameStyle,
+ auto_layout: bool,
+ session_serialization: bool,
+ serialize_pane_viewport: bool,
+ scrollback_lines_to_serialize: Option<usize>,
+ session_is_mirrored: bool,
+ copy_options: CopyOptions,
+ debug: bool,
+ session_name: String,
+ peer_sessions_cache: BTreeMap<String, SessionInfo>, // String is the session name, can
+ // also be this session
+ resurrectable_sessions_cache: BTreeMap<String, Duration>, // String is the session name,
+ // duration is its creation time
+ default_layout: Box<Layout>,
+ default_shell: PathBuf,
+ styled_underlines: bool,
+ osc8_hyperlinks: bool,
+ arrow_fonts: bool,
+ #[cfg_attr(test, allow(dead_code))]
+ layout_dir: Option<PathBuf>,
+ #[cfg_attr(test, allow(dead_code))]
+ default_layout_name: Option<String>,
+ explicitly_disable_kitty_keyboard_protocol: bool,
+ support_kitty_graphics_protocol: bool,
+ default_editor: Option<PathBuf>,
+ web_clients_allowed: bool,
+ web_sharing: WebSharing,
+ current_pane_group: Rc<RefCell<PaneGroups>>,
+ advanced_mouse_actions: bool,
+ mouse_scroll_resize: bool,
+ mouse_hover_effects: bool,
+ visual_bell: bool,
+ focus_follows_mouse: bool,
+ mouse_click_through: bool,
+ currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
+ // the below are the configured values - the ones that will be set if and when the web server
+ // is brought online
+ web_server_ip: IpAddr,
+ web_server_port: u16,
+ render_blocker: RenderBlocker,
+ watcher_clients: HashMap<ClientId, WatcherState>,
+ followed_client_id: Option<ClientId>,
+ cached_layouts: Vec<LayoutInfo>,
+ cached_layout_errors: Vec<LayoutWithError>,
+ pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>,
+ plugins_need_ansi_pane_contents: bool,
+ background_plugin_subscriptions: HashMap<(PluginId, ClientId), HashSet<EventType>>,
+ next_forward_token: u32,
+ pending_forwarded_queries: HashMap<u32, PendingForwardEntry>,
+ forward_queue: VecDeque<PendingForward>,
+ forward_in_flight_token: Option<u32>,
+ nested_guest_tracker: NestedGuestTracker,
+ nested_ancestry: Vec<String>,
+ nested_via_client_id: Option<ClientId>,
+ nested_fullscreen_panes: HashSet<PaneId>,
+ own_fullscreen_requested: bool,
+ reported_guest_fullscreen: HashMap<PaneId, bool>,
+ host_fullscreen: bool,
+ dimmed_clients: HashSet<ClientId>,
+ nested_guest_choices: HashMap<(ClientId, PaneId), NestedGuestChoice>,
+ guest_ascend_keys: HashMap<PaneId, Vec<KeyWithModifier>>,
+ host_descend_keys: Vec<KeyWithModifier>,
+ host_descended: bool,
+ host_terminal_theme_mode: Option<HostTerminalThemeMode>,
+ host_theme_dark_styling: Option<Styling>,
+ host_theme_light_styling: Option<Styling>,
+ nested_session_handling: NestedSessionHandling,
+ mobile_state: MobileState,
+ mobile_render_gate: MobileRenderGate,
+}
+
+/// A pending forward waiting to be dispatched once the current in-flight
+/// forward's barrier reply (or timeout) arrives.
+#[derive(Debug, Clone)]
+struct PendingForward {
+ token: u32,
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+}
+
+/// A forward currently in flight (dispatched to the client, waiting
+/// for a reply). Retains the `HostQuery` classification so we can
+/// fall back to cache-synthesized replies when the live reply is
+/// empty (no client attached, client-side timeout, etc.) without
+/// re-parsing byte strings.
+#[derive(Debug, Clone)]
+struct PendingForwardEntry {
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+}
+
+/// Reserved sentinel token for Zellij's own startup batch of host
+/// queries (pixel dims, bg/fg, sync-output, palette registers). The
+/// existing fire-and-forget startup plumbing is unchanged today; the
+/// sentinel exists so a future migration can route the startup batch
+/// through the same forwarded-query mechanism as per-pane queries
+/// while keeping its replies routable to `Screen`'s cached state
+/// rather than to a pane's pty. Reserved value 0 is excluded from
+/// `next_forward_token` allocation by the wrap-skip in
+/// `forward_host_query`.
+const STARTUP_SENTINEL_TOKEN: u32 = 0;
+
+/// Server-side deadline for a forwarded host query. If no
+/// `ForwardedReplyFromHost` arrives within this window, the server
+/// synthesizes an empty reply for itself so the in-flight slot
+/// releases and queued forwards continue to drain.
+///
+/// This recovers the worst-case where the connected client doesn't
+/// understand `ServerToClientMsg::ForwardQueryToHost` (an old client
+/// against a new server) — without it the slot would deadlock and
+/// every app-issued host query would hang. The window is generous
+/// relative to the 500 ms client-side deadline so a well-behaved new
+/// client always replies first; only the old-client and
+/// network-pathological cases ever see this fire.
+const SERVER_FORWARD_TIMEOUT_MS: u64 = 1000;
+
+impl Screen {
+ /// Creates and returns a new [`Screen`].
+ pub fn new(
+ bus: Bus<ScreenInstruction>,
+ client_attributes: &ClientAttributes,
+ max_panes: Option<usize>,
+ mode_info: ModeInfo,
+ pane_frame_style: PaneFrameStyle,
+ auto_layout: bool,
+ session_is_mirrored: bool,
+ copy_options: CopyOptions,
+ debug: bool,
+ default_layout: Box<Layout>,
+ default_layout_name: Option<String>,
+ default_shell: PathBuf,
+ session_serialization: bool,
+ serialize_pane_viewport: bool,
+ scrollback_lines_to_serialize: Option<usize>,
+ styled_underlines: bool,
+ osc8_hyperlinks: bool,
+ arrow_fonts: bool,
+ layout_dir: Option<PathBuf>,
+ explicitly_disable_kitty_keyboard_protocol: bool,
+ support_kitty_graphics_protocol: bool,
+ stacked_resize: bool,
+ stacked_pane_list: bool,
+ default_editor: Option<PathBuf>,
+ web_clients_allowed: bool,
+ web_sharing: WebSharing,
+ advanced_mouse_actions: bool,
+ mouse_scroll_resize: bool,
+ mouse_hover_effects: bool,
+ visual_bell: bool,
+ focus_follows_mouse: bool,
+ mouse_click_through: bool,
+ web_server_ip: IpAddr,
+ web_server_port: u16,
+ nested_session_handling: NestedSessionHandling,
+ ) -> Self {
+ let session_name = mode_info.session_name.clone().unwrap_or_default();
+ let session_info = SessionInfo::new(session_name.clone());
+ let mut peer_sessions_cache = BTreeMap::new();
+ let resurrectable_sessions_cache = BTreeMap::new();
+ peer_sessions_cache.insert(session_name.clone(), session_info);
+ let current_pane_group = PaneGroups::new(bus.senders.clone());
+ Screen {
+ bus,
+ max_panes,
+ size: client_attributes.size,
+ pixel_dimensions: Default::default(),
+ character_cell_size: Rc::new(RefCell::new(None)),
+ stacked_resize: Rc::new(RefCell::new(stacked_resize)),
+ stacked_pane_list: Rc::new(RefCell::new(stacked_pane_list)),
+ sixel_image_store: Rc::new(RefCell::new(SixelImageStore::default())),
+ kitty_image_store: Rc::new(RefCell::new(KittyImageStore::default())),
+ kitty_host_capabilities: Rc::new(RefCell::new(HashMap::new())),
+ client_kitty_host_state: Rc::new(RefCell::new(HashMap::new())),
+ style: client_attributes.style,
+ connected_clients: Rc::new(RefCell::new(HashMap::new())),
+ active_tab_ids: BTreeMap::new(),
+ client_sizes: HashMap::new(),
+ global_last_active_tab_id: 0,
+ tabs: BTreeMap::new(),
+ last_single_pane_tab_names: HashMap::new(),
+ terminal_emulator_colors: Rc::new(RefCell::new(Palette::default())),
+ terminal_emulator_color_codes: Rc::new(RefCell::new(HashMap::new())),
+ tab_history: BTreeMap::new(),
+ pane_history: BTreeMap::new(),
+ mode_info: BTreeMap::new(),
+ default_mode_info: mode_info,
+ pane_frame_style,
+ auto_layout,
+ session_is_mirrored,
+ copy_options,
+ debug,
+ session_name,
+ peer_sessions_cache,
+ default_layout,
+ default_layout_name,
+ default_shell,
+ session_serialization,
+ serialize_pane_viewport,
+ scrollback_lines_to_serialize,
+ styled_underlines,
+ osc8_hyperlinks,
+ arrow_fonts,
+ resurrectable_sessions_cache,
+ layout_dir,
+ explicitly_disable_kitty_keyboard_protocol,
+ support_kitty_graphics_protocol,
+ default_editor,
+ web_clients_allowed,
+ web_sharing,
+ current_pane_group: Rc::new(RefCell::new(current_pane_group)),
+ currently_marking_pane_group: Rc::new(RefCell::new(HashMap::new())),
+ advanced_mouse_actions,
+ mouse_scroll_resize,
+ mouse_hover_effects,
+ visual_bell,
+ focus_follows_mouse,
+ mouse_click_through,
+ web_server_ip,
+ web_server_port,
+ render_blocker: RenderBlocker::new(100),
+ watcher_clients: HashMap::new(),
+ followed_client_id: None,
+ cached_layouts: vec![],
+ cached_layout_errors: vec![],
+ pane_render_subscribers: HashMap::new(),
+ plugins_need_ansi_pane_contents: false,
+ background_plugin_subscriptions: HashMap::new(),
+ next_forward_token: 1, // 0 is reserved as the startup sentinel
+ pending_forwarded_queries: HashMap::new(),
+ forward_queue: VecDeque::new(),
+ forward_in_flight_token: None,
+ nested_guest_tracker: NestedGuestTracker::default(),
+ nested_ancestry: vec![],
+ nested_via_client_id: None,
+ nested_fullscreen_panes: HashSet::new(),
+ own_fullscreen_requested: false,
+ reported_guest_fullscreen: HashMap::new(),
+ host_fullscreen: false,
+ dimmed_clients: HashSet::new(),
+ nested_guest_choices: HashMap::new(),
+ guest_ascend_keys: HashMap::new(),
+ host_descend_keys: vec![],
+ host_descended: false,
+ host_terminal_theme_mode: None,
+ host_theme_dark_styling: None,
+ host_theme_light_styling: None,
+ mobile_state: MobileState::default(),
+ nested_session_handling,
+ mobile_render_gate: MobileRenderGate::default(),
+ }
+ }
+
+ fn client_is_web(&self, client_id: ClientId) -> bool {
+ self.connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false)
+ }
+
+ fn try_lift_mobile_gate(&mut self, client_id: ClientId) -> bool {
+ let is_web_client = self.client_is_web(client_id);
+ let reported_size = self.client_sizes.get(&client_id).copied();
+ let lifted = self
+ .mobile_render_gate
+ .try_reveal(client_id, is_web_client, reported_size);
+ if lifted {
+ self.force_render_mobile_tab(client_id);
+ }
+ lifted
+ }
+
+ fn force_render_mobile_tab(&mut self, client_id: ClientId) {
+ if let Some(tab_id) = self.mobile_state.mobile_tab_id(client_id) {
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ tab.set_should_clear_display_before_rendering();
+ tab.set_force_render();
+ }
+ }
+ }
+
+ fn ungate_clients_for_mobile_plugin(&mut self, plugin_id: u32) {
+ if self.mobile_render_gate.is_empty() {
+ return;
+ }
+ let Some(tab_id) = self
+ .tabs
+ .values()
+ .find(|tab| tab.has_plugin(plugin_id))
+ .map(|tab| tab.id)
+ else {
+ return;
+ };
+ for client_id in self.mobile_render_gate.gated_clients() {
+ if self.mobile_state.mobile_tab_id(client_id) == Some(tab_id) {
+ self.mobile_render_gate.ungate(client_id);
+ }
+ }
+ }
+
+ fn get_new_tab_id(&self) -> usize {
+ if let Some(id) = self.tabs.keys().last() {
+ *id + 1
+ } else {
+ 0
+ }
+ }
+
+ /// Gets a tab by its stable ID (BTreeMap key).
+ ///
+ /// Use this when you have a tab ID from active_tab_ids, tab_history, or tab.id.
+ fn get_tab_by_id(&self, id: usize) -> Option<&Tab> {
+ self.tabs.get(&id)
+ }
+
+ /// Gets a mutable tab by its stable ID (BTreeMap key).
+ fn get_tab_by_id_mut(&mut self, id: usize) -> Option<&mut Tab> {
+ self.tabs.get_mut(&id)
+ }
+
+ /// Gets a mutable tab by its display position (0-based).
+ fn get_tab_by_position_mut(&mut self, position: usize) -> Option<&mut Tab> {
+ self.tabs.values_mut().find(|t| t.position == position)
+ }
+
+ /// Gets the stable ID of the tab at the given display position.
+ ///
+ /// Use this to convert position → ID for BTreeMap lookups.
+ fn get_tab_id_at_position(&self, position: usize) -> Option<usize> {
+ self.tabs
+ .values()
+ .find(|t| t.position == position)
+ .map(|t| t.id)
+ }
+
+ /// Gets the display position of the tab with the given ID.
+ ///
+ /// Use this to convert ID → position for user-facing operations.
+ fn get_tab_position_by_id(&self, id: usize) -> Option<usize> {
+ self.tabs.get(&id).map(|t| t.position)
+ }
+
+ pub fn tab_visible_to(&self, client_id: ClientId, tab_id: usize) -> bool {
+ match self.tabs.get(&tab_id) {
+ Some(tab) => match &tab.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ },
+ None => false,
+ }
+ }
+
+ pub fn is_in_mobile_mode(&self, client_id: ClientId) -> bool {
+ self.mobile_state.is_in_mobile_mode(client_id)
+ }
+
+ pub(crate) fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> Result<()> {
+ if let ShadowFocusOutcome::NewlyApplied =
+ self.mobile_state
+ .apply_shadow_focus(client_id, pane_id, &mut self.tabs)
+ {
+ self.log_and_report_session_state()?;
+ self.render(None)?;
+ }
+ Ok(())
+ }
+
+ pub fn enter_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to enter mobile mode for client {client_id}");
+
+ if self.mobile_state.is_in_mobile_mode(client_id) {
+ return Ok(());
+ }
+
+ let prior = self.active_tab_ids.get(&client_id).copied();
+ self.mobile_state.set_previous_tab(client_id, prior);
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+
+ let tab_layout = MobileState::mobile_tab_layout().with_context(err_context)?;
+
+ let tab_id = self.get_new_tab_id();
+ self.new_tab(
+ tab_id,
+ (vec![], vec![]),
+ Some("Mobile".to_string()),
+ Some(client_id),
+ )
+ .with_context(err_context)?;
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ let mut set = HashSet::new();
+ set.insert(client_id);
+ tab.visible_to = Some(set);
+ }
+ self.mobile_state.register_tab(client_id, tab_id);
+
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+
+ let client_is_gated = self.mobile_render_gate.is_gated(client_id);
+ if client_is_gated {
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::HoldMobileRender(client_id))
+ .with_context(err_context)?;
+ }
+
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::NewTab(
+ None,
+ None,
+ Some(tab_layout),
+ vec![],
+ tab_id,
+ None,
+ false,
+ true,
+ (client_id, is_web_client),
+ None,
+ ))
+ .with_context(err_context)?;
+
+ Ok(())
+ }
+
+ pub fn exit_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to exit mobile mode for client {client_id}");
+
+ self.mobile_render_gate.ungate(client_id);
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+
+ let Some((mobile_tab_id, prior_tab_id)) = self.mobile_state.begin_exit(client_id) else {
+ return Ok(());
+ };
+
+ let destination = prior_tab_id
+ .filter(|id| self.tabs.contains_key(id) && self.tab_visible_to(client_id, *id))
+ .or_else(|| {
+ let mobile_tab_ids = self.mobile_state.mobile_tab_ids();
+ self.tabs
+ .values()
+ .filter(|t| {
+ t.id != mobile_tab_id
+ && !mobile_tab_ids.contains(&t.id)
+ && match &t.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ }
+ })
+ .min_by_key(|t| t.position)
+ .map(|t| t.id)
+ });
+
+ if let Some(dest_id) = destination {
+ if let Some(dest_pos) = self.get_tab_position_by_id(dest_id) {
+ self.switch_active_tab(dest_pos, None, true, client_id)
+ .with_context(err_context)?;
+ }
+ }
+
+ if self.tabs.contains_key(&mobile_tab_id) {
+ self.close_tab_by_id(mobile_tab_id)
+ .with_context(err_context)?;
+ }
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+
+ Ok(())
+ }
+
+ pub fn toggle_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ if self.is_in_mobile_mode(client_id) {
+ self.exit_mobile_mode(client_id)
+ } else {
+ self.enter_mobile_mode(client_id)
+ }
+ }
+
+ pub fn reevaluate_mobile_mode(
+ &mut self,
+ client_id: ClientId,
+ new_size: Size,
+ mobile_layout: MobileLayoutConfiguration,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ ) -> Result<()> {
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+ let should_be_mobile = mobile_layout.should_route_to_mobile(
+ is_web_client,
+ new_size.cols,
+ new_size.rows,
+ threshold_cols,
+ threshold_rows,
+ );
+ let is_mobile = self.is_in_mobile_mode(client_id);
+ let was_auto = self.mobile_state.was_auto_entered(client_id);
+ if should_be_mobile && !is_mobile {
+ self.enter_mobile_mode(client_id)?;
+ self.mobile_state.mark_auto_entered(client_id);
+ self.render(None)?;
+ } else if !should_be_mobile && is_mobile && was_auto {
+ self.exit_mobile_mode(client_id)?;
+ self.render(None)?;
+ }
+ if !should_be_mobile && self.mobile_render_gate.is_gated(client_id) {
+ self.mobile_render_gate.ungate(client_id);
+ self.render(None)?;
+ }
+ Ok(())
+ }
+
+ fn visible_tab_positions_for_client(&self, client_id: ClientId) -> Vec<usize> {
+ let mut positions: Vec<usize> = self
+ .tabs
+ .values()
+ .filter(|t| match &t.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ })
+ .map(|t| t.position)
+ .collect();
+ positions.sort_unstable();
+ positions
+ }
+
+ fn move_clients_from_closed_tab(
+ &mut self,
+ client_ids_and_mode_infos: Vec<(ClientId, ModeInfo)>,
+ ) -> Result<()> {
+ let err_context = || "failed to move clients from closed tab".to_string();
+
+ if self.tabs.is_empty() {
+ Err::<(), _>(anyhow!(
+ "No tabs left, cannot move clients: {:?} from closed tab",
+ client_ids_and_mode_infos
+ ))
+ .with_context(err_context)
+ .non_fatal();
+
+ return Ok(());
+ }
+ let first_tab_index = *self
+ .tabs
+ .keys()
+ .next()
+ .context("screen contained no tabs")
+ .with_context(err_context)?;
+ for (client_id, client_mode_info) in client_ids_and_mode_infos {
+ let client_tab_history = self.tab_history.entry(client_id).or_insert_with(Vec::new);
+ if let Some(client_previous_tab) = client_tab_history.pop() {
+ if let Some(client_active_tab) = self.tabs.get_mut(&client_previous_tab) {
+ self.active_tab_ids.insert(client_id, client_previous_tab);
+ client_active_tab
+ .add_client(client_id, Some(client_mode_info))
+ .with_context(err_context)?;
+ continue;
+ }
+ }
+ self.active_tab_ids.insert(client_id, first_tab_index);
+ self.tabs
+ .get_mut(&first_tab_index)
+ .with_context(err_context)?
+ .add_client(client_id, Some(client_mode_info))
+ .with_context(err_context)?;
+ }
+ Ok(())
+ }
+
+ fn move_suppressed_panes_from_closed_tab(
+ &mut self,
+ suppressed_panes: SuppressedPanes,
+ ) -> Result<()> {
+ // TODO: this is not entirely accurate, these also sometimes contain a pane who's
+ // scrollback is being edited - in this case we need to close it or to move it to the
+ // appropriate tab
+ let err_context = || "Failed to move suppressed panes from closed tab";
+ let first_tab_index = *self
+ .tabs
+ .keys()
+ .next()
+ .context("screen contains no tabs")
+ .with_context(err_context)?;
+ self.tabs
+ .get_mut(&first_tab_index)
+ .with_context(err_context)?
+ .add_suppressed_panes(suppressed_panes);
+ Ok(())
+ }
+
+ fn move_clients_between_tabs(
+ &mut self,
+ source_tab_index: usize,
+ destination_tab_index: usize,
+ update_mode_infos: bool,
+ clients_to_move: Option<Vec<ClientId>>,
+ ) -> Result<()> {
+ let err_context = || {
+ format!(
+ "failed to move clients from tab {source_tab_index} to tab {destination_tab_index}"
+ )
+ };
+
+ // None ==> move all clients
+ let drained_clients = self
+ .get_indexed_tab_mut(source_tab_index)
+ .map(|t| t.drain_connected_clients(clients_to_move));
+
+ if let Some(client_mode_info_in_source_tab) = drained_clients {
+ let destination_tab = self
+ .get_indexed_tab_mut(destination_tab_index)
+ .context("failed to get destination tab by index")
+ .with_context(err_context)?;
+ destination_tab
+ .add_multiple_clients(client_mode_info_in_source_tab)
+ .with_context(err_context)?;
+ if update_mode_infos {
+ destination_tab
+ .update_input_modes()
+ .with_context(err_context)?;
+ }
+ destination_tab.set_force_render();
+ if destination_tab.has_stack_lists() {
+ destination_tab.set_should_clear_display_before_rendering();
+ }
+ destination_tab.visible(true).with_context(err_context)?;
+ }
+ Ok(())
+ }
+
+ fn update_client_tab_focus(&mut self, client_id: ClientId, new_tab_index: usize) {
+ match self.active_tab_ids.remove(&client_id) {
+ Some(old_active_index) => {
+ self.active_tab_ids.insert(client_id, new_tab_index);
+ let client_tab_history = self.tab_history.entry(client_id).or_insert_with(Vec::new);
+ client_tab_history.retain(|&e| e != new_tab_index);
+ client_tab_history.push(old_active_index);
+ },
+ None => {
+ self.active_tab_ids.insert(client_id, new_tab_index);
+ },
+ }
+ self.clear_bell_for_focused_pane(client_id);
+ }
+
+ /// A helper function to switch to a new tab at specified position.
+ fn switch_active_tab(
+ &mut self,
+ new_tab_pos: usize,
+ should_change_pane_focus: Option<Direction>,
+ update_mode_infos: bool,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let err_context = || {
+ format!(
+ "Failed to switch to active tab at position {new_tab_pos} for client id: {client_id:?}"
+ )
+ };
+
+ if let Some(new_tab) = self.tabs.values().find(|t| t.position == new_tab_pos) {
+ match self.get_active_tab(client_id) {
+ Ok(current_tab) => {
+ // If new active tab is same as the current one, do nothing.
+ if current_tab.position == new_tab_pos {
+ return Ok(());
+ }
+
+ let current_tab_index = current_tab.id;
+ let new_tab_index = new_tab.id;
+ let affected_client_ids: Vec<ClientId> = if self.session_is_mirrored {
+ self.connected_clients
+ .borrow()
+ .iter()
+ .map(|(c, _i)| *c)
+ .collect()
+ } else {
+ vec![client_id]
+ };
+ let old_focused_panes: Vec<(ClientId, Option<PaneId>)> = affected_client_ids
+ .iter()
+ .map(|c| (*c, self.get_active_pane_id(c)))
+ .collect();
+ if self.session_is_mirrored {
+ self.move_clients_between_tabs(
+ current_tab_index,
+ new_tab_index,
+ update_mode_infos,
+ None,
+ )
+ .with_context(err_context)?;
+ let all_connected_clients: Vec<ClientId> = self
+ .connected_clients
+ .borrow()
+ .iter()
+ .map(|(c, _i)| *c)
+ .collect();
+ for client_id in all_connected_clients {
+ self.update_client_tab_focus(client_id, new_tab_index);
+ match (
+ should_change_pane_focus,
+ self.get_indexed_tab_mut(new_tab_index),
+ ) {
+ (Some(direction), Some(new_tab)) => {
+ new_tab.focus_pane_on_edge(direction, client_id);
+ },
+ _ => {},
+ }
+ }
+ } else {
+ self.move_clients_between_tabs(
+ current_tab_index,
+ new_tab_index,
+ update_mode_infos,
+ Some(vec![client_id]),
+ )
+ .with_context(err_context)?;
+ match (
+ should_change_pane_focus,
+ self.get_indexed_tab_mut(new_tab_index),
+ ) {
+ (Some(direction), Some(new_tab)) => {
+ new_tab.focus_pane_on_edge(direction, client_id);
+ },
+ _ => {},
+ }
+ self.update_client_tab_focus(client_id, new_tab_index);
+ }
+
+ if let Some(current_tab) = self.get_indexed_tab_mut(current_tab_index) {
+ if current_tab.has_no_connected_clients() {
+ current_tab.visible(false).with_context(err_context)?;
+ }
+ } else {
+ Err::<(), _>(anyhow!("Tab index {:?} not found", current_tab_index))
+ .with_context(err_context)
+ .non_fatal();
+ }
+
+ // Clear tab bell notification for the newly active tab
+ if let Some(new_tab) = self.tabs.get_mut(&new_tab_index) {
+ let tab_id = new_tab.id;
+ new_tab.clear_tab_bell_notification();
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id));
+ }
+
+ self.recompute_tab_size(current_tab_index)
+ .with_context(err_context)?;
+ self.recompute_tab_size(new_tab_index)
+ .with_context(err_context)?;
+
+ self.log_and_report_session_state()
+ .with_context(err_context)?;
+ for (affected_client_id, old_pane_id) in old_focused_panes {
+ let new_pane_id = self.get_active_pane_id(&affected_client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ self.report_key_passthrough_state(affected_client_id, old, new);
+ }
+ }
+ return self.render(None).with_context(err_context);
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ }
+ }
+ Ok(())
+ }
+
+ /// A helper function to switch to a new tab with specified name. Return true if tab [name] has
+ /// been created, else false.
+ fn switch_active_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> {
+ match self
+ .tabs
+ .values()
+ .find(|t| t.name == name && self.tab_visible_to(client_id, t.id))
+ {
+ Some(new_tab) => {
+ self.switch_active_tab(new_tab.position, None, true, client_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
+ /// Sets this [`Screen`]'s active [`Tab`] to the next tab.
+ pub fn switch_tab_next(
+ &mut self,
+ should_change_pane_focus: Option<Direction>,
+ update_mode_infos: bool,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let err_context = || format!("failed to switch to next tab for client {client_id}");
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+
+ if let Some(client_id) = client_id {
+ match self.get_active_tab(client_id) {
+ Ok(active_tab) => {
+ let active_tab_pos = active_tab.position;
+ let visible = self.visible_tab_positions_for_client(client_id);
+ if visible.is_empty() {
+ return Ok(());
+ }
+ let new_tab_pos = match visible.iter().position(|p| *p > active_tab_pos) {
+ Some(idx) => visible[idx],
+ None => visible[0],
+ };
+ return self.switch_active_tab(
+ new_tab_pos,
+ should_change_pane_focus,
+ update_mode_infos,
+ client_id,
+ );
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ }
+ }
+ Ok(())
+ }
+
+ /// Sets this [`Screen`]'s active [`Tab`] to the previous tab.
+ pub fn switch_tab_prev(
+ &mut self,
+ should_change_pane_focus: Option<Direction>,
+ update_mode_infos: bool,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let err_context = || format!("failed to switch to previous tab for client {client_id}");
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+
+ if let Some(client_id) = client_id {
+ match self.get_active_tab(client_id) {
+ Ok(active_tab) => {
+ let active_tab_pos = active_tab.position;
+ let visible = self.visible_tab_positions_for_client(client_id);
+ if visible.is_empty() {
+ return Ok(());
+ }
+ let new_tab_pos = match visible.iter().rev().find(|p| **p < active_tab_pos) {
+ Some(p) => *p,
+ None => *visible.last().unwrap(),
+ };
+
+ return self.switch_active_tab(
+ new_tab_pos,
+ should_change_pane_focus,
+ update_mode_infos,
+ client_id,
+ );
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ }
+ }
+ Ok(())
+ }
+
+ pub fn go_to_tab(&mut self, tab_index: usize, client_id: ClientId) -> Result<()> {
+ let target_pos = tab_index.saturating_sub(1);
+ if let Some(target_id) = self.get_tab_id_at_position(target_pos) {
+ if !self.tab_visible_to(client_id, target_id) {
+ return Ok(());
+ }
+ }
+ self.switch_active_tab(target_pos, None, true, client_id)
+ }
+
+ pub fn go_to_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> {
+ self.switch_active_tab_name(name, client_id)
+ }
+
+ fn close_tab_by_id(&mut self, tab_id: usize) -> Result<()> {
+ let err_context = || format!("failed to close tab at index {tab_id:?}");
+
+ let mut tab_to_close = self.tabs.remove(&tab_id).with_context(err_context)?;
+ self.mobile_state.remove_fit_for_tab(tab_id);
+ let mut pane_ids = tab_to_close.get_all_pane_ids();
+
+ // here we extract the suppressed panes (these are background panes that don't care which
+ // tab they are in, and in the future we should probably make them global to screen rather
+ // than to each tab) and move them to another tab if there is one
+ let suppressed_panes = tab_to_close.extract_suppressed_panes();
+ for suppressed_pane_id in suppressed_panes.keys() {
+ pane_ids.retain(|p| p != suppressed_pane_id);
+ }
+
+ let _ = self.bus.senders.send_to_plugin(PluginInstruction::Update(
+ pane_ids
+ .iter()
+ .copied()
+ .map(|p_id| (None, None, Event::PaneClosed(p_id.into())))
+ .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
+ self.bus
+ .senders
+ .send_to_pty(PtyInstruction::CloseTab(pane_ids))
+ .with_context(err_context)?;
+ if self.tabs.is_empty() {
+ self.active_tab_ids.clear();
+ self.bus
+ .senders
+ .send_to_server(ServerInstruction::Render(None))
+ .with_context(err_context)
+ } else {
+ let client_mode_infos_in_closed_tab = tab_to_close.drain_connected_clients(None);
+ self.move_clients_from_closed_tab(client_mode_infos_in_closed_tab)
+ .with_context(err_context)?;
+ self.move_suppressed_panes_from_closed_tab(suppressed_panes)
+ .with_context(err_context)?;
+ let visible_tab_indices: HashSet<usize> =
+ self.active_tab_ids.values().copied().collect();
+ for t in self.tabs.values_mut() {
+ if visible_tab_indices.contains(&t.id) {
+ t.set_force_render();
+ t.visible(true).with_context(err_context)?;
+ }
+ if t.position > tab_to_close.position {
+ t.position -= 1;
+ }
+ }
+ self.log_and_report_session_state()
+ .with_context(err_context)?;
+ self.render(None).with_context(err_context)
+ }
+ }
+
+ // Closes the client_id's focused tab
+ pub fn close_tab(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to close tab for client {client_id:?}");
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+
+ match client_id {
+ Some(client_id) => {
+ let active_tab_index = *self
+ .active_tab_ids
+ .get(&client_id)
+ .with_context(err_context)?;
+ self.close_tab_by_id(active_tab_index)
+ .with_context(err_context)
+ },
+ None => Ok(()),
+ }
+ }
+
+ pub fn resize_to_screen(&mut self, new_screen_size: Size) -> Result<()> {
+ let err_context = || format!("failed to resize to screen size: {new_screen_size:#?}");
+
+ if self.size != new_screen_size {
+ self.size = new_screen_size;
+ for tab in self.tabs.values_mut() {
+ tab.resize_whole_tab(new_screen_size)
+ .with_context(err_context)?;
+ tab.set_force_render();
+ }
+ self.log_and_report_session_state()
+ .with_context(err_context)?;
+ self.render(None).with_context(err_context)
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn set_client_size(&mut self, client_id: ClientId, size: Size) {
+ self.client_sizes.insert(client_id, size);
+ }
+
+ pub fn recompute_tab_size(&mut self, tab_id: usize) -> Result<()> {
+ let err_context = || format!("failed to recompute size for tab {tab_id}");
+
+ if self.mobile_state.has_fit(tab_id) {
+ for _ in 0..FIT_RESIZE_MAX_ITERS {
+ let Some(new_size) = self.compute_fit_size(tab_id) else {
+ break;
+ };
+ let Some(tab) = self.tabs.get_mut(&tab_id) else {
+ break;
+ };
+ if tab.size == new_size {
+ break;
+ }
+ tab.resize_whole_tab(new_size).with_context(err_context)?;
+ }
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ tab.set_should_clear_display_before_rendering();
+ tab.set_force_render();
+ }
+ return Ok(());
+ }
+
+ let mut rows: Vec<usize> = Vec::new();
+ let mut cols: Vec<usize> = Vec::new();
+ for (client_id, active_tab) in self.active_tab_ids.iter() {
+ if *active_tab == tab_id {
+ if let Some(size) = self.client_sizes.get(client_id) {
+ rows.push(size.rows);
+ cols.push(size.cols);
+ }
+ }
+ }
+ if rows.is_empty() || cols.is_empty() {
+ return Ok(());
+ }
+ rows.sort_unstable();
+ cols.sort_unstable();
+ let new_size = Size {
+ rows: rows[0],
+ cols: cols[0],
+ };
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ if tab.size != new_size {
+ tab.resize_whole_tab(new_size).with_context(err_context)?;
+ tab.set_force_render();
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn compute_fit_size(&self, tab_id: usize) -> Option<Size> {
+ self.mobile_state.compute_fit_size(tab_id, &self.tabs)
+ }
+
+ pub fn set_tab_fit(
+ &mut self,
+ client_id: ClientId,
+ tab_id: usize,
+ pane_id: PaneId,
+ size: Size,
+ ) -> Result<()> {
+ self.mobile_state
+ .set_fit(client_id, tab_id, pane_id, size, &mut self.tabs);
+ self.recompute_tab_size(tab_id)
+ }
+
+ pub fn exit_fit_mode(&mut self, client_id: ClientId) -> Result<bool> {
+ match self
+ .mobile_state
+ .clear_fit_owned_by(client_id, &mut self.tabs)
+ {
+ Some(tab_id) => {
+ self.recompute_tab_size(tab_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
+ pub(crate) fn clear_fit_for_closed_pane(&mut self, pane_id: PaneId) -> Result<bool> {
+ match self.mobile_state.clear_fit_for_pane(pane_id) {
+ Some(tab_id) => {
+ self.recompute_tab_size(tab_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
+ pub fn update_pixel_dimensions(&mut self, pixel_dimensions: PixelDimensions) {
+ self.pixel_dimensions.merge(pixel_dimensions);
+ if let Some(character_cell_size) = self.pixel_dimensions.character_cell_size {
+ *self.character_cell_size.borrow_mut() = Some(character_cell_size);
+ } else if let Some(text_area_size) = self.pixel_dimensions.text_area_size {
+ let character_cell_size_height = text_area_size.height / self.size.rows;
+ let character_cell_size_width = text_area_size.width / self.size.cols;
+ let character_cell_size = SizeInPixels {
+ height: character_cell_size_height,
+ width: character_cell_size_width,
+ };
+ *self.character_cell_size.borrow_mut() = Some(character_cell_size);
+ }
+ }
+
+ pub fn update_kitty_graphics_support(&mut self, client_id: ClientId, supported: bool) {
+ let supported = supported && self.support_kitty_graphics_protocol;
+ self.kitty_host_capabilities
+ .borrow_mut()
+ .insert(client_id, supported);
+ self.push_kitty_host_support_to_tabs();
+ }
+
+ fn kitty_host_support_aggregate(&self) -> Option<KittyHostSupport> {
+ if !self.support_kitty_graphics_protocol {
+ return Some(KittyHostSupport::ProtocolDisabled);
+ }
+ let capabilities = self.kitty_host_capabilities.borrow();
+ if capabilities.is_empty() {
+ None
+ } else {
+ Some(KittyHostSupport::from_host_capability(
+ capabilities.values().any(|supported| *supported),
+ ))
+ }
+ }
+
+ fn push_kitty_host_support_to_tabs(&mut self) {
+ if let Some(aggregate) = self.kitty_host_support_aggregate() {
+ for tab in self.tabs.values_mut() {
+ tab.update_kitty_host_support(aggregate);
+ }
+ }
+ }
+
+ pub fn update_terminal_background_color(&mut self, background_color_instruction: String) {
+ if let Some(AnsiCode::RgbCode((r, g, b))) =
+ xparse_color(background_color_instruction.as_bytes())
+ {
+ let bg_palette_color = PaletteColor::Rgb((r, g, b));
+ self.terminal_emulator_colors.borrow_mut().bg = bg_palette_color;
+ }
+ }
+
+ pub fn update_terminal_foreground_color(&mut self, foreground_color_instruction: String) {
+ if let Some(AnsiCode::RgbCode((r, g, b))) =
+ xparse_color(foreground_color_instruction.as_bytes())
+ {
+ let fg_palette_color = PaletteColor::Rgb((r, g, b));
+ self.terminal_emulator_colors.borrow_mut().fg = fg_palette_color;
+ }
+ }
+
+ pub fn update_terminal_color_registers(&mut self, color_registers: Vec<(usize, String)>) {
+ let mut terminal_emulator_color_codes = self.terminal_emulator_color_codes.borrow_mut();
+ for (color_register, color_sequence) in color_registers {
+ terminal_emulator_color_codes.insert(color_register, color_sequence);
+ }
+ }
+
+ /// Enqueue a whitelisted host-terminal query from pane `pane_id`.
+ /// Queries are serialized globally: at most one is in flight to the
+ /// client at a time; the rest wait in `forward_queue`. Returns the
+ /// allocated token so callers (tests) can observe it if useful.
+ pub fn forward_host_query(
+ &mut self,
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+ ) -> u32 {
+ // ColorPaletteMode is answered locally — Zellij already knows
+ // the host's mode from its own startup query and from
+ // unsolicited DSR 997 updates. Skip the entire forwarding
+ // machinery (no token, no in-flight slot, no host round-trip)
+ // and write the reply straight to the originating pane's pty.
+ if let crate::host_query::HostQuery::ColorPaletteMode = query {
+ self.answer_color_palette_mode_query_locally(pane_id);
+ return STARTUP_SENTINEL_TOKEN; // sentinel: no real forward happened
+ }
+ let token = self.next_forward_token;
+ // Skip over the reserved sentinel (0) on wrap; allocate a fresh
+ // u32 for every forward.
+ self.next_forward_token = self.next_forward_token.wrapping_add(1);
+ if self.next_forward_token == STARTUP_SENTINEL_TOKEN {
+ self.next_forward_token = 1;
+ }
+ if self.forward_in_flight_token.is_some() {
+ self.forward_queue.push_back(PendingForward {
+ token,
+ pane_id,
+ query,
+ });
+ } else {
+ self.dispatch_forward(token, pane_id, query);
+ }
+ token
+ }
+
+ fn answer_color_palette_mode_query_locally(&mut self, pane_id: PaneId) {
+ if matches!(pane_id, PaneId::Plugin(_)) {
+ return;
+ }
+ let code: u8 = match self.effective_host_terminal_theme_mode() {
+ HostTerminalThemeMode::Dark => 1,
+ HostTerminalThemeMode::Light => 2,
+ };
+ let reply = format!("\u{1b}[?997;{}n", code).into_bytes();
+ // Route via Tab so the reply lands on the pane in the correct
+ // stream position and any PTY input the app emitted while
+ // waiting is replayed.
+ let _ = self.resume_pane_after_forward(pane_id, reply);
+ }
+
+ fn effective_host_terminal_theme_mode(&self) -> HostTerminalThemeMode {
+ self.host_terminal_theme_mode.unwrap_or_else(|| {
+ match detect_theme_hue(self.style.colors.text_unselected.background) {
+ ThemeHue::Dark => HostTerminalThemeMode::Dark,
+ ThemeHue::Light => HostTerminalThemeMode::Light,
+ }
+ })
+ }
+
+ /// Dispatch a forward to the client and mark the slot as in-flight.
+ /// Spawns a one-shot timeout task on the global tokio runtime that
+ /// synthesizes an empty reply for `token` after
+ /// `SERVER_FORWARD_TIMEOUT_MS`. The handler's token-equality guard
+ /// makes the timeout a no-op if a real reply arrived first, so no
+ /// explicit cancellation is needed.
+ fn dispatch_forward(
+ &mut self,
+ token: u32,
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+ ) {
+ let query_bytes = query.to_query_bytes();
+ self.pending_forwarded_queries
+ .insert(token, PendingForwardEntry { pane_id, query });
+ self.forward_in_flight_token = Some(token);
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::ForwardQueryToHost(token, query_bytes));
+ let senders = self.bus.senders.clone();
+ crate::global_async_runtime::get_tokio_runtime().spawn(async move {
+ tokio::time::sleep(std::time::Duration::from_millis(SERVER_FORWARD_TIMEOUT_MS)).await;
+ let _ = senders.send_to_screen(ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ });
+ });
+ }
+
+ /// Handle a host-reply observed by the client for token `token`.
+ /// Writes the bytes to the originating pane's pty (if still present)
+ /// and releases the in-flight slot so the next queued forward can
+ /// dispatch.
+ ///
+ /// An empty `reply_bytes` is treated as "nobody answered" — either
+ /// because no client was attached to forward the query, or the
+ /// chosen client's host timed out. In that case we try to answer
+ /// from Zellij's cached view of the host (pixel dims, bg/fg,
+ /// palette) so the pane still gets a well-formed reply instead of
+ /// zero bytes. If the cache has nothing relevant either, the pane
+ /// receives an empty write and the app decides what to do.
+ pub fn handle_forwarded_reply_from_host(
+ &mut self,
+ token: u32,
+ reply_bytes: Vec<u8>,
+ ) -> Result<()> {
+ // Stale-reply guard. Both the real `ForwardedReplyFromHost`
+ // path and the server-side timeout path land here. If a real
+ // reply landed first (releasing the slot AND dispatching the
+ // next queued forward), the late timeout's empty reply for the
+ // already-cleared token must be a no-op — releasing the slot
+ // again would clobber the new in-flight forward. The same
+ // logic also rejects duplicate replies and replies for tokens
+ // belonging to a previously-closed pane.
+ if self.forward_in_flight_token != Some(token) {
+ log::debug!(
+ "Dropping stale forwarded reply (token={}, in-flight={:?}, len={} bytes)",
+ token,
+ self.forward_in_flight_token,
+ reply_bytes.len(),
+ );
+ return Ok(());
+ }
+ if let Some(entry) = self.pending_forwarded_queries.remove(&token) {
+ let PendingForwardEntry { pane_id, query } = entry;
+ match pane_id {
+ PaneId::Terminal(_) => {
+ let payload = if reply_bytes.is_empty() {
+ self.synthesize_cached_reply(&query)
+ } else {
+ reply_bytes
+ };
+ // Route via Tab so the reply lands on the pane in
+ // the correct stream position and any PTY input
+ // the app emitted while waiting is replayed.
+ self.resume_pane_after_forward(pane_id, payload)?;
+ },
+ PaneId::Plugin(_) => {
+ // Plugin panes do not issue whitelisted host queries;
+ // if we reach here the mapping was populated
+ // erroneously. Drop the reply.
+ log::warn!(
+ "Discarding host reply for plugin pane (token={}); plugins do not forward CSI/OSC queries",
+ token
+ );
+ },
+ }
+ }
+ // Release the slot and dispatch the next queued forward, if any.
+ self.forward_in_flight_token = None;
+ if let Some(next) = self.forward_queue.pop_front() {
+ self.dispatch_forward(next.token, next.pane_id, next.query);
+ }
+ Ok(())
+ }
+
+ /// Deliver a forwarded reply (or cache-fallback synthesis, or a
+ /// locally-answered query payload) to the originating pane via
+ /// the owning Tab. The Tab handler writes the bytes to PTY and
+ /// then re-feeds any PTY input that was buffered while the pane
+ /// was forward-paused, preserving query/reply ordering.
+ pub fn resume_pane_after_forward(
+ &mut self,
+ pane_id: PaneId,
+ reply_bytes: Vec<u8>,
+ ) -> Result<()> {
+ let terminal_id = match pane_id {
+ PaneId::Terminal(id) => id,
+ PaneId::Plugin(_) => {
+ // Plugin panes do not forward queries — dropping is
+ // the correct behaviour matching the existing
+ // forwarding path's plugin-pane guard.
+ return Ok(());
+ },
+ };
+ let mut found = false;
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.resume_pane_after_forward(terminal_id, reply_bytes.clone())
+ .non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ // No tab owns the pane — the pane was closed between the
+ // forward dispatch and the reply arrival. The terminal id
+ // is probably defunct; the write goes to the PTY writer
+ // anyway because an empty payload is the canonical "host
+ // declined" reply some apps rely on, and a non-empty
+ // payload may still be deliverable until the pty drains.
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(reply_bytes, terminal_id, None));
+ log::debug!(
+ "ResumePaneAfterForward: pane {:?} not in any tab, fell back to direct PTY write",
+ pane_id
+ );
+ }
+ Ok(())
+ }
+
+ pub fn handle_nested_session_message_from_pane(
+ &mut self,
+ pane_id: PaneId,
+ message: NestedSessionMessage,
+ ) {
+ match message {
+ NestedSessionMessage::Announce { session_name, .. } => {
+ self.handle_nested_guest_announce(pane_id, session_name);
+ },
+ NestedSessionMessage::Pong => {
+ self.nested_guest_tracker.on_pong(pane_id, Instant::now());
+ log::debug!("nested session guest in pane {:?} ponged", pane_id);
+ },
+ NestedSessionMessage::Bye => {
+ log::info!(
+ "nested session guest in pane {:?} sent bye, clearing guest flag",
+ pane_id
+ );
+ self.clear_nested_guest(pane_id);
+ },
+ NestedSessionMessage::FocusHost { direction } => {
+ let clients_focused_on_pane = self.clients_with_pane_focused(pane_id);
+ match direction {
+ None => {
+ self.unset_nested_fullscreen_if_active(pane_id);
+ for client_id in clients_focused_on_pane {
+ self.nested_guest_choices
+ .insert((client_id, pane_id), NestedGuestChoice::Dismissed);
+ self.sync_guest_choice_indicator(client_id, pane_id);
+ self.set_client_dimmed(client_id, false, None);
+ let _ = self.bus.senders.send_to_server(
+ ServerInstruction::KeyPassthroughChanged(
+ client_id, pane_id, pane_id, false, None, true,
+ ),
+ );
+ }
+ let _ = self.render(None);
+ },
+ Some(direction) => {
+ for client_id in clients_focused_on_pane {
+ let new_pane_id = self
+ .tabs
+ .values_mut()
+ .find(|tab| tab.has_pane_with_pid(&pane_id))
+ .and_then(|tab| {
+ tab.focus_pane_adjacent_to(pane_id, direction, client_id)
+ });
+ match new_pane_id {
+ Some(new_pane_id) => {
+ self.report_key_passthrough_state_with_direction(
+ client_id,
+ pane_id,
+ new_pane_id,
+ Some(direction),
+ );
+ let _ = self.render(None);
+ let _ = self.log_and_report_session_state();
+ },
+ None => {
+ self.bubble_focus_to_host(client_id, direction);
+ },
+ }
+ }
+ },
+ }
+ },
+ NestedSessionMessage::ShortcutUpdate { ascend_keys, .. } => {
+ if !self.nested_guest_tracker.is_tracked(pane_id) {
+ log::debug!(
+ "ignoring nested shortcut update from non-live guest pane {:?}",
+ pane_id
+ );
+ return;
+ }
+ self.guest_ascend_keys.insert(pane_id, ascend_keys);
+ self.refresh_nested_ascend_keys_for_pane(pane_id);
+ },
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen } => {
+ if !self.nested_guest_tracker.is_tracked(pane_id) {
+ log::debug!(
+ "ignoring nested fullscreen request from non-live guest pane {:?}",
+ pane_id
+ );
+ return;
+ }
+ self.apply_nested_guest_fullscreen(pane_id, fullscreen);
+ if let Some(client_id) = self.nested_via_client_id {
+ let payload = nested_session::encode_payload(
+ &NestedSessionMessage::ToggleHostFullscreen { fullscreen },
+ );
+ let _ = self.bus.senders.send_to_server(
+ ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload),
+ );
+ }
+ let _ = self.render(None);
+ let _ = self.log_and_report_session_state();
+ },
+ other => {
+ log::debug!(
+ "dropping unsupported nested session message from pane {:?}: {:?}",
+ pane_id,
+ other
+ );
+ },
+ }
+ }
+
+ fn handle_nested_guest_announce(&mut self, pane_id: PaneId, guest_session_name: String) {
+ use crate::nested_guest::AnnounceKind;
+ let terminal_id = match pane_id {
+ PaneId::Terminal(terminal_id) => terminal_id,
+ PaneId::Plugin(_) => return,
+ };
+ let announce_kind = self
+ .nested_guest_tracker
+ .on_announce(pane_id, Instant::now());
+ let is_fresh = announce_kind == AnnounceKind::New;
+ if is_fresh {
+ self.nested_guest_choices
+ .retain(|(_, choice_pane_id), _| *choice_pane_id != pane_id);
+ }
+ let connected_client_ids: Vec<ClientId> =
+ self.connected_clients.borrow().keys().copied().collect();
+ let guest_modal_shortcuts = self.guest_modal_shortcuts();
+ let owning_tab = self
+ .tabs
+ .values_mut()
+ .find(|tab| tab.has_pane_with_pid(&pane_id));
+ match owning_tab {
+ Some(tab) => {
+ tab.set_pane_is_nested_guest(pane_id, true);
+ tab.set_guest_session_name_on_pane(pane_id, Some(guest_session_name.clone()));
+ tab.set_guest_modal_shortcuts_on_pane(pane_id, guest_modal_shortcuts);
+ if is_fresh {
+ tab.clear_all_guest_choice_indicators_on_pane(pane_id);
+ if self.nested_session_handling == NestedSessionHandling::Ask {
+ tab.set_guest_modal_on_pane(pane_id, &connected_client_ids);
+ }
+ }
+ },
+ None => {
+ self.nested_guest_tracker.remove(pane_id);
+ return;
+ },
+ }
+ let mut ancestry = self.nested_ancestry.clone();
+ ancestry.push(self.session_name.clone());
+ let announce_ack = NestedSessionMessage::AnnounceAck {
+ ancestry,
+ capabilities: vec![NestedSessionCapability::NestedControl],
+ descend_keys: self.own_descend_shortcut(),
+ };
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(
+ nested_session::encode_frame(&announce_ack),
+ terminal_id,
+ None,
+ ));
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StartNestedGuestPing(pane_id));
+ if is_fresh {
+ self.auto_apply_nested_session_handling(pane_id, &connected_client_ids);
+ } else {
+ self.revive_nested_guest(pane_id);
+ }
+ let _ = self.render(None);
+ log::info!(
+ "nested session handshake ({:?}): guest session {:?} announced in pane {:?}, sent announce_ack",
+ announce_kind,
+ guest_session_name,
+ pane_id
+ );
+ }
+
+ fn suspend_nested_guest(&mut self, pane_id: PaneId) {
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id));
+ self.unset_nested_fullscreen_if_active(pane_id);
+ self.reported_guest_fullscreen.remove(&pane_id);
+ let affected_clients: Vec<ClientId> = self
+ .clients_with_pane_focused(pane_id)
+ .into_iter()
+ .chain(self.dimmed_clients.iter().copied())
+ .collect();
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_all_guest_modals_on_pane(pane_id);
+ break;
+ }
+ }
+ for client_id in affected_clients {
+ self.set_client_dimmed(client_id, false, None);
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::KeyPassthroughChanged(
+ client_id, pane_id, pane_id, false, None, false,
+ ));
+ }
+ }
+
+ fn revive_nested_guest(&mut self, pane_id: PaneId) {
+ self.broadcast_ancestry_update_to_guests();
+ self.sync_nested_guest_fullscreen_state();
+ let client_ids: Vec<ClientId> = self.connected_clients.borrow().keys().copied().collect();
+ for client_id in client_ids {
+ self.sync_guest_choice_indicator(client_id, pane_id);
+ if self.get_active_pane_id(&client_id) == Some(pane_id) {
+ self.report_key_passthrough_state(client_id, pane_id, pane_id);
+ }
+ }
+ }
+
+ fn handle_guest_modal_choice(
+ &mut self,
+ client_id: ClientId,
+ pane_id: PaneId,
+ outcome: GuestModalOutcome,
+ ) {
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_guest_modal_on_pane(pane_id, client_id);
+ break;
+ }
+ }
+ match outcome {
+ GuestModalOutcome::Descend => {
+ self.nested_guest_choices
+ .insert((client_id, pane_id), NestedGuestChoice::Descend);
+ self.sync_guest_choice_indicator(client_id, pane_id);
+ if self.get_active_pane_id(&client_id) == Some(pane_id) {
+ self.report_key_passthrough_state(client_id, pane_id, pane_id);
+ }
+ let _ = self.render(None);
+ let _ = self.log_and_report_session_state();
+ },
+ GuestModalOutcome::Zoom => {
+ self.nested_guest_choices
+ .insert((client_id, pane_id), NestedGuestChoice::Zoom);
+ self.sync_guest_choice_indicator(client_id, pane_id);
+ if self.get_active_pane_id(&client_id) == Some(pane_id) {
+ self.report_key_passthrough_state(client_id, pane_id, pane_id);
+ }
+ self.apply_nested_guest_fullscreen(pane_id, true);
+ self.sync_nested_guest_fullscreen_state();
+ if let Some(via_client_id) = self.nested_via_client_id {
+ let payload = nested_session::encode_payload(
+ &NestedSessionMessage::ToggleHostFullscreen { fullscreen: true },
+ );
+ let _ = self.bus.senders.send_to_server(
+ ServerInstruction::EmitNestedSessionFrameToClient(via_client_id, payload),
+ );
+ }
+ let _ = self.render(None);
+ let _ = self.log_and_report_session_state();
+ },
+ }
+ }
+
+ fn record_nested_guest_choice_for_client(
+ &mut self,
+ client_id: ClientId,
+ pane_id: PaneId,
+ choice: NestedGuestChoice,
+ ) {
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_guest_modal_on_pane(pane_id, client_id);
+ break;
+ }
+ }
+ self.nested_guest_choices
+ .insert((client_id, pane_id), choice);
+ self.sync_guest_choice_indicator(client_id, pane_id);
+ if self.get_active_pane_id(&client_id) == Some(pane_id) {
+ self.report_key_passthrough_state(client_id, pane_id, pane_id);
+ }
+ }
+
+ fn auto_apply_nested_session_handling(&mut self, pane_id: PaneId, client_ids: &[ClientId]) {
+ match self.nested_session_handling {
+ NestedSessionHandling::Descend => {
+ for client_id in client_ids {
+ self.record_nested_guest_choice_for_client(
+ *client_id,
+ pane_id,
+ NestedGuestChoice::Descend,
+ );
+ }
+ },
+ NestedSessionHandling::Fullscreen => {
+ for client_id in client_ids {
+ self.record_nested_guest_choice_for_client(
+ *client_id,
+ pane_id,
+ NestedGuestChoice::Zoom,
+ );
+ }
+ self.apply_nested_guest_fullscreen(pane_id, true);
+ self.sync_nested_guest_fullscreen_state();
+ if let Some(via_client_id) = self.nested_via_client_id {
+ let payload = nested_session::encode_payload(
+ &NestedSessionMessage::ToggleHostFullscreen { fullscreen: true },
+ );
+ let _ = self.bus.senders.send_to_server(
+ ServerInstruction::EmitNestedSessionFrameToClient(via_client_id, payload),
+ );
+ }
+ },
+ NestedSessionHandling::Ask | NestedSessionHandling::Never => {},
+ }
+ }
+
+ fn focus_guest_session(&mut self, client_id: ClientId) {
+ let focused_pane_id = match self.get_active_pane_id(&client_id) {
+ Some(pane_id) => pane_id,
+ None => return,
+ };
+ let is_live_guest = self.tabs.values().any(|tab| {
+ tab.has_pane_with_pid(&focused_pane_id) && tab.is_pane_nested_guest(focused_pane_id)
+ }) && self.nested_guest_tracker.is_tracked(focused_pane_id);
+ if !is_live_guest {
+ return;
+ }
+ self.nested_guest_choices
+ .insert((client_id, focused_pane_id), NestedGuestChoice::Descend);
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&focused_pane_id) {
+ tab.clear_guest_modal_on_pane(focused_pane_id, client_id);
+ break;
+ }
+ }
+ self.sync_guest_choice_indicator(client_id, focused_pane_id);
+ self.report_key_passthrough_state(client_id, focused_pane_id, focused_pane_id);
+ let _ = self.render(None);
+ let _ = self.log_and_report_session_state();
+ }
+
+ pub fn clear_nested_guest(&mut self, pane_id: PaneId) {
+ self.nested_guest_tracker.remove(pane_id);
+ self.guest_ascend_keys.remove(&pane_id);
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id));
+ self.unset_nested_fullscreen_if_active(pane_id);
+ self.reported_guest_fullscreen.remove(&pane_id);
+ let mut affected_clients: Vec<ClientId> = self
+ .nested_guest_choices
+ .iter()
+ .filter(|((_, choice_pane_id), choice)| {
+ *choice_pane_id == pane_id && choice.enters_passthrough()
+ })
+ .map(|((choice_client_id, _), _)| *choice_client_id)
+ .collect();
+ self.nested_guest_choices
+ .retain(|(_, choice_pane_id), _| *choice_pane_id != pane_id);
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_pane_is_nested_guest(pane_id, false);
+ tab.clear_all_guest_modals_on_pane(pane_id);
+ tab.clear_all_guest_choice_indicators_on_pane(pane_id);
+ break;
+ }
+ }
+
+ for client_id in self.clients_with_pane_focused(pane_id) {
+ if !affected_clients.contains(&client_id) {
+ affected_clients.push(client_id);
+ }
+ }
+ for client_id in affected_clients {
+ self.set_client_dimmed(client_id, false, None);
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::KeyPassthroughChanged(
+ client_id, pane_id, pane_id, false, None, false,
+ ));
+ }
+ }
+
+ fn show_guest_modals_for_new_client(&mut self, client_id: ClientId) {
+ let mut live_guest_panes = vec![];
+ for tab in self.tabs.values() {
+ for pane_id in tab.nested_guest_pane_ids() {
+ if self.nested_guest_tracker.is_tracked(pane_id) {
+ live_guest_panes.push(pane_id);
+ }
+ }
+ }
+ for pane_id in live_guest_panes {
+ if self
+ .nested_guest_choices
+ .contains_key(&(client_id, pane_id))
+ {
+ continue;
+ }
+ match self.nested_session_handling {
+ NestedSessionHandling::Ask => {
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_guest_modal_on_pane(pane_id, &[client_id]);
+ break;
+ }
+ }
+ },
+ NestedSessionHandling::Descend => {
+ self.record_nested_guest_choice_for_client(
+ client_id,
+ pane_id,
+ NestedGuestChoice::Descend,
+ );
+ },
+ NestedSessionHandling::Fullscreen => {
+ self.record_nested_guest_choice_for_client(
+ client_id,
+ pane_id,
+ NestedGuestChoice::Zoom,
+ );
+ },
+ NestedSessionHandling::Never => {},
+ }
+ }
+ }
+
+ fn should_route_keys_to_pane(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ for tab in self.tabs.values() {
+ if tab.has_pane_with_pid(&pane_id) {
+ let is_guest = tab.is_pane_nested_guest(pane_id);
+ let is_tracked = self.nested_guest_tracker.is_tracked(pane_id);
+ let has_descended = self
+ .nested_guest_choices
+ .get(&(client_id, pane_id))
+ .map(|choice| choice.enters_passthrough())
+ .unwrap_or(false);
+ return is_guest && is_tracked && has_descended;
+ }
+ }
+ false
+ }
+
+ fn downgrade_zoom_choice_to_descend(&mut self, pane_id: PaneId) {
+ let mut affected_clients = vec![];
+ for ((client_id, choice_pane_id), choice) in self.nested_guest_choices.iter_mut() {
+ if *choice_pane_id == pane_id && *choice == NestedGuestChoice::Zoom {
+ *choice = NestedGuestChoice::Descend;
+ affected_clients.push(*client_id);
+ }
+ }
+ for client_id in affected_clients {
+ self.sync_guest_choice_indicator(client_id, pane_id);
+ }
+ }
+
+ fn guest_choice_indicator_for(
+ &self,
+ client_id: ClientId,
+ pane_id: PaneId,
+ ) -> Option<GuestChoiceIndicator> {
+ match self.nested_guest_choices.get(&(client_id, pane_id)) {
+ Some(NestedGuestChoice::Descend) => Some(GuestChoiceIndicator::Descended),
+ Some(NestedGuestChoice::Dismissed) => Some(GuestChoiceIndicator::Dismissed),
+ Some(NestedGuestChoice::Zoom) | None => None,
+ }
+ }
+
+ fn sync_guest_choice_indicator(&mut self, client_id: ClientId, pane_id: PaneId) {
+ let indicator = self.guest_choice_indicator_for(client_id, pane_id);
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_guest_choice_indicator_on_pane(pane_id, client_id, indicator);
+ break;
+ }
+ }
+ }
+
+ fn report_key_passthrough_state(
+ &mut self,
+ client_id: ClientId,
+ old_pane_id: PaneId,
+ new_pane_id: PaneId,
+ ) {
+ self.report_key_passthrough_state_with_direction(client_id, old_pane_id, new_pane_id, None);
+ }
+
+ fn report_key_passthrough_state_with_direction(
+ &mut self,
+ client_id: ClientId,
+ old_pane_id: PaneId,
+ new_pane_id: PaneId,
+ entered_from_direction: Option<Direction>,
+ ) {
+ let should_route = self.should_route_keys_to_pane(client_id, new_pane_id);
+ let guest_pane_id = if should_route {
+ Some(new_pane_id)
+ } else {
+ None
+ };
+ self.set_client_dimmed(client_id, should_route, guest_pane_id);
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::KeyPassthroughChanged(
+ client_id,
+ old_pane_id,
+ new_pane_id,
+ should_route,
+ entered_from_direction,
+ true,
+ ));
+ }
+
+ fn set_client_dimmed(
+ &mut self,
+ client_id: ClientId,
+ dimmed: bool,
+ guest_pane_id: Option<PaneId>,
+ ) {
+ let changed = if dimmed {
+ self.dimmed_clients.insert(client_id)
+ } else {
+ self.dimmed_clients.remove(&client_id)
+ };
+ let ascend_keys = if dimmed {
+ guest_pane_id
+ .and_then(|pane_id| self.guest_ascend_keys.get(&pane_id).cloned())
+ .unwrap_or_default()
+ } else {
+ vec![]
+ };
+ let ascend_keys_changed = self
+ .mode_info
+ .get(&client_id)
+ .map(|mode_info| mode_info.nested_ascend_keys != ascend_keys)
+ .unwrap_or_else(|| !ascend_keys.is_empty());
+ if !changed && !ascend_keys_changed {
+ return;
+ }
+ let mode_info = self
+ .mode_info
+ .entry(client_id)
+ .or_insert_with(|| self.default_mode_info.clone());
+ mode_info.session_dimmed = if dimmed { Some(true) } else { None };
+ mode_info.nested_ascend_keys = ascend_keys;
+ let mode_info = mode_info.clone();
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info.clone(), client_id);
+ tab.mark_active_pane_for_rerender(client_id);
+ tab.set_client_dimmed(client_id, dimmed);
+ }
+ for tab in self.tabs.values_mut() {
+ let _ = tab.update_input_modes();
+ }
+ let _ = self.render(None);
+ }
+
+ fn base_input_mode(&self) -> InputMode {
+ self.default_mode_info.base_mode.unwrap_or_default()
+ }
+
+ fn own_ascend_shortcut(&self) -> Vec<KeyWithModifier> {
+ shortcut_for_action(
+ &self.default_mode_info.keybinds,
+ self.base_input_mode(),
+ |action| matches!(action, Action::FocusHostSession),
+ )
+ .unwrap_or_default()
+ }
+
+ fn own_descend_shortcut(&self) -> Vec<KeyWithModifier> {
+ shortcut_for_action(
+ &self.default_mode_info.keybinds,
+ self.base_input_mode(),
+ |action| matches!(action, Action::FocusGuestSession),
+ )
+ .unwrap_or_default()
+ }
+
+ fn own_host_zoom_shortcut(&self) -> Vec<KeyWithModifier> {
+ shortcut_for_action(
+ &self.default_mode_info.keybinds,
+ self.base_input_mode(),
+ |action| matches!(action, Action::ToggleHostFullscreen),
+ )
+ .unwrap_or_default()
+ }
+
+ fn guest_modal_shortcuts(&self) -> GuestModalShortcuts {
+ GuestModalShortcuts {
+ zoom: format_guest_modal_shortcut(&self.own_host_zoom_shortcut()),
+ ascend: format_guest_modal_shortcut(&self.own_ascend_shortcut()),
+ descend: format_guest_modal_shortcut(&self.own_descend_shortcut()),
+ }
+ }
+
+ fn refresh_nested_ascend_keys_for_pane(&mut self, pane_id: PaneId) {
+ let keys = self
+ .guest_ascend_keys
+ .get(&pane_id)
+ .cloned()
+ .unwrap_or_default();
+ let dimmed_client_ids: Vec<ClientId> = self.dimmed_clients.iter().copied().collect();
+ let mut clients_to_update: Vec<(ClientId, ModeInfo)> = vec![];
+ for client_id in dimmed_client_ids {
+ if self.get_active_pane_id(&client_id) != Some(pane_id) {
+ continue;
+ }
+ if let Some(mode_info) = self.mode_info.get_mut(&client_id) {
+ if mode_info.nested_ascend_keys != keys {
+ mode_info.nested_ascend_keys = keys.clone();
+ clients_to_update.push((client_id, mode_info.clone()));
+ }
+ }
+ }
+ if clients_to_update.is_empty() {
+ return;
+ }
+ for (client_id, mode_info) in clients_to_update {
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info.clone(), client_id);
+ }
+ }
+ for tab in self.tabs.values_mut() {
+ let _ = tab.update_input_modes();
+ }
+ let _ = self.render(None);
+ }
+
+ fn set_host_descended(&mut self, host_descended: bool) {
+ if self.host_descended != host_descended {
+ self.host_descended = host_descended;
+ self.update_all_clients_nesting_mode_info();
+ }
+ }
+
+ fn broadcast_nested_shortcuts(&mut self) {
+ if let Some(client_id) = self.nested_via_client_id {
+ let payload = nested_session::encode_payload(&NestedSessionMessage::ShortcutUpdate {
+ ascend_keys: self.own_ascend_shortcut(),
+ descend_keys: vec![],
+ });
+ let _ =
+ self.bus
+ .senders
+ .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient(
+ client_id, payload,
+ ));
+ }
+ let frame = nested_session::encode_frame(&NestedSessionMessage::ShortcutUpdate {
+ ascend_keys: vec![],
+ descend_keys: self.own_descend_shortcut(),
+ });
+ for terminal_id in self.nested_guest_terminal_ids() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(frame.clone(), terminal_id, None));
+ }
+ }
+
+ fn update_all_clients_nesting_mode_info(&mut self) {
+ let ancestry = self.nested_ancestry.clone();
+ let host_fullscreen = if self.host_fullscreen {
+ Some(true)
+ } else {
+ None
+ };
+ let session_ascended = if !ancestry.is_empty() && !self.host_descended {
+ Some(true)
+ } else {
+ None
+ };
+ let descend_keys = self.host_descend_keys.clone();
+ self.default_mode_info.session_ancestry = ancestry.clone();
+ self.default_mode_info.host_fullscreen = host_fullscreen;
+ self.default_mode_info.session_ascended = session_ascended;
+ self.default_mode_info.nested_descend_keys = descend_keys.clone();
+ let mut client_ids: Vec<ClientId> = self.mode_info.keys().copied().collect();
+ for connected_client_id in self.connected_clients.borrow().keys() {
+ if !client_ids.contains(connected_client_id) {
+ client_ids.push(*connected_client_id);
+ }
+ }
+ for client_id in client_ids {
+ let mode_info = self
+ .mode_info
+ .entry(client_id)
+ .or_insert_with(|| self.default_mode_info.clone());
+ mode_info.session_ancestry = ancestry.clone();
+ mode_info.host_fullscreen = host_fullscreen;
+ mode_info.session_ascended = session_ascended;
+ mode_info.nested_descend_keys = descend_keys.clone();
+ let mode_info = mode_info.clone();
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info.clone(), client_id);
+ }
+ }
+ for tab in self.tabs.values_mut() {
+ let _ = tab.update_input_modes();
+ }
+ let _ = self.render(None);
+ }
+
+ fn nested_guest_terminal_ids(&self) -> Vec<u32> {
+ let mut terminal_ids = vec![];
+ for tab in self.tabs.values() {
+ for pane_id in tab.nested_guest_pane_ids() {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ if self.nested_guest_tracker.is_tracked(pane_id) {
+ terminal_ids.push(terminal_id);
+ }
+ }
+ }
+ }
+ terminal_ids
+ }
+
+ fn broadcast_ancestry_update_to_guests(&self) {
+ let mut ancestry = self.nested_ancestry.clone();
+ ancestry.push(self.session_name.clone());
+ let message = NestedSessionMessage::AncestryUpdate { ancestry };
+ let frame = nested_session::encode_frame(&message);
+ for terminal_id in self.nested_guest_terminal_ids() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(frame.clone(), terminal_id, None));
+ }
+ }
+
+ fn apply_nested_guest_fullscreen(&mut self, pane_id: PaneId, fullscreen: bool) {
+ let mut actually_fullscreen = false;
+ let mut displaced_pane_id = None;
+ if let Some(tab) = self
+ .tabs
+ .values_mut()
+ .find(|tab| tab.has_pane_with_pid(&pane_id))
+ {
+ let currently_fullscreen =
+ tab.fullscreen_pane_id() == Some(pane_id) && tab.fullscreen_covers_ui();
+ if fullscreen && !currently_fullscreen {
+ let existing_fullscreen = tab.fullscreen_pane_id();
+ if existing_fullscreen.is_some() && existing_fullscreen != Some(pane_id) {
+ displaced_pane_id = existing_fullscreen;
+ tab.toggle_pane_no_ui_fullscreen(existing_fullscreen.unwrap());
+ }
+ tab.toggle_pane_no_ui_fullscreen(pane_id);
+ } else if !fullscreen && currently_fullscreen {
+ tab.toggle_pane_no_ui_fullscreen(pane_id);
+ }
+ actually_fullscreen =
+ tab.fullscreen_pane_id() == Some(pane_id) && tab.fullscreen_covers_ui();
+ }
+ if let Some(displaced_pane_id) = displaced_pane_id {
+ self.nested_fullscreen_panes.remove(&displaced_pane_id);
+ }
+ if actually_fullscreen {
+ self.nested_fullscreen_panes.insert(pane_id);
+ } else {
+ self.nested_fullscreen_panes.remove(&pane_id);
+ }
+ }
+
+ fn unset_nested_fullscreen_if_active(&mut self, pane_id: PaneId) {
+ if !self.nested_fullscreen_panes.contains(&pane_id) {
+ return;
+ }
+ self.apply_nested_guest_fullscreen(pane_id, false);
+ if let Some(client_id) = self.nested_via_client_id {
+ let payload =
+ nested_session::encode_payload(&NestedSessionMessage::ToggleHostFullscreen {
+ fullscreen: false,
+ });
+ let _ =
+ self.bus
+ .senders
+ .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient(
+ client_id, payload,
+ ));
+ }
+ }
+
+ fn sync_nested_guest_fullscreen_state(&mut self) {
+ let mut actual: HashMap<PaneId, bool> = HashMap::new();
+ for tab in self.tabs.values() {
+ let fullscreen_pane_id = tab.fullscreen_pane_id();
+ let covers_ui = tab.fullscreen_covers_ui();
+ for pane_id in tab.nested_guest_pane_ids() {
+ if !self.nested_guest_tracker.is_tracked(pane_id) {
+ continue;
+ }
+ let is_fullscreen = covers_ui && fullscreen_pane_id == Some(pane_id);
+ actual.insert(pane_id, is_fullscreen);
+ }
+ }
+ let mut updates: Vec<(u32, bool)> = vec![];
+ for (pane_id, is_fullscreen) in actual.iter() {
+ let previously = self.reported_guest_fullscreen.get(pane_id).copied();
+ if previously != Some(*is_fullscreen) {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ updates.push((*terminal_id, *is_fullscreen));
+ }
+ self.reported_guest_fullscreen
+ .insert(*pane_id, *is_fullscreen);
+ }
+ }
+ self.reported_guest_fullscreen
+ .retain(|pane_id, _| actual.contains_key(pane_id));
+ for (pane_id, is_fullscreen) in actual.iter() {
+ if !is_fullscreen {
+ self.downgrade_zoom_choice_to_descend(*pane_id);
+ }
+ }
+ for (terminal_id, fullscreen) in updates {
+ let message = NestedSessionMessage::FullscreenState { fullscreen };
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(
+ nested_session::encode_frame(&message),
+ terminal_id,
+ None,
+ ));
+ }
+ }
+
+ fn clients_with_pane_focused(&self, pane_id: PaneId) -> Vec<ClientId> {
+ let mut clients = vec![];
+ for (client_id, tab_id) in self.active_tab_ids.iter() {
+ if let Some(focused) = self
+ .tabs
+ .get(tab_id)
+ .and_then(|t| t.get_active_pane_id(*client_id))
+ {
+ if focused == pane_id {
+ clients.push(*client_id);
+ }
+ }
+ }
+ clients
+ }
+
+ fn bubble_focus_to_host(&self, client_id: ClientId, direction: Direction) {
+ let payload = nested_session::encode_payload(&NestedSessionMessage::FocusHost {
+ direction: Some(direction),
+ });
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::EmitNestedSessionFrameToClient(
+ client_id, payload,
+ ));
+ }
+
+ pub fn handle_nested_session_message_from_host(
+ &mut self,
+ client_id: ClientId,
+ message: NestedSessionMessage,
+ ) {
+ match message {
+ NestedSessionMessage::AnnounceAck {
+ ancestry,
+ descend_keys,
+ ..
+ } => {
+ log::info!(
+ "nested session handshake complete: this session is nested inside ancestry {:?}",
+ ancestry
+ );
+ self.nested_ancestry = ancestry;
+ self.nested_via_client_id = Some(client_id);
+ self.host_descend_keys = descend_keys;
+ self.update_all_clients_nesting_mode_info();
+ self.broadcast_ancestry_update_to_guests();
+ let payload =
+ nested_session::encode_payload(&NestedSessionMessage::ShortcutUpdate {
+ ascend_keys: self.own_ascend_shortcut(),
+ descend_keys: vec![],
+ });
+ let _ = self.bus.senders.send_to_server(
+ ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload),
+ );
+ },
+ NestedSessionMessage::ShortcutUpdate { descend_keys, .. } => {
+ if self.host_descend_keys != descend_keys {
+ self.host_descend_keys = descend_keys;
+ self.update_all_clients_nesting_mode_info();
+ }
+ },
+ NestedSessionMessage::FullscreenState { fullscreen } => {
+ self.host_fullscreen = fullscreen;
+ self.own_fullscreen_requested = fullscreen;
+ self.update_all_clients_nesting_mode_info();
+ },
+ NestedSessionMessage::AncestryUpdate { ancestry } => {
+ self.nested_ancestry = ancestry;
+ self.update_all_clients_nesting_mode_info();
+ self.broadcast_ancestry_update_to_guests();
+ },
+ NestedSessionMessage::FocusGained {
+ from_direction: Some(direction),
+ } => {
+ self.set_host_descended(true);
+ let old_pane_id = self.get_active_pane_id(&client_id);
+ if let Ok(tab) = self.get_active_tab_mut(client_id) {
+ tab.focus_pane_on_edge(direction, client_id);
+ }
+ let new_pane_id = self.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ self.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(direction),
+ );
+ }
+ let _ = self.render(None);
+ let _ = self.log_and_report_session_state();
+ },
+ NestedSessionMessage::FocusGained {
+ from_direction: None,
+ } => {
+ self.set_host_descended(true);
+ let _ = self.render(None);
+ },
+ NestedSessionMessage::FocusLost => {
+ self.set_host_descended(false);
+ let _ = self.render(None);
+ },
+ other => {
+ log::debug!(
+ "dropping unsupported nested session message from host: {:?}",
+ other
+ );
+ },
+ }
+ }
+
+ pub fn handle_nested_guest_ping_tick(&mut self, pane_id: PaneId) {
+ use crate::nested_guest::GuestPingTickAction;
+ match self.nested_guest_tracker.on_tick(pane_id, Instant::now()) {
+ GuestPingTickAction::SendPing => {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(
+ nested_session::encode_frame(&NestedSessionMessage::Ping),
+ terminal_id,
+ None,
+ ));
+ log::debug!("pinged nested session guest in pane {:?}", pane_id);
+ }
+ },
+ GuestPingTickAction::Suspended => {
+ log::info!(
+ "nested session guest in pane {:?} missed the pong timeout, suspending until it re-announces",
+ pane_id
+ );
+ self.suspend_nested_guest(pane_id);
+ },
+ GuestPingTickAction::Unknown => {
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopNestedGuestPing(pane_id));
+ },
+ }
+ }
+
+ /// Build a reply for `query` from whatever host state Zellij has
+ /// cached, or an empty `Vec` if nothing relevant is cached. The
+ /// classification happened at intercept time (Grid produces a
+ /// [`HostQuery`]), so this method is pure structural dispatch —
+ /// no re-parsing of bytes.
+ fn synthesize_cached_reply(&self, query: &crate::host_query::HostQuery) -> Vec<u8> {
+ use crate::host_query::HostQuery;
+ match query {
+ HostQuery::TextAreaPixelSize => self
+ .pixel_dimensions
+ .text_area_size
+ .map(|dims| format!("\x1b[4;{};{}t", dims.height, dims.width).into_bytes())
+ .unwrap_or_default(),
+ HostQuery::CharacterCellPixelSize => self
+ .pixel_dimensions
+ .character_cell_size
+ .map(|dims| format!("\x1b[6;{};{}t", dims.height, dims.width).into_bytes())
+ .unwrap_or_default(),
+ HostQuery::DefaultForeground { terminator }
+ | HostQuery::DefaultBackground { terminator } => {
+ let palette = self.terminal_emulator_colors.borrow();
+ let (channel, color) = match query {
+ HostQuery::DefaultForeground { .. } => (10u32, palette.fg),
+ HostQuery::DefaultBackground { .. } => (11u32, palette.bg),
+ _ => unreachable!(),
+ };
+ if let PaletteColor::Rgb((r, g, b)) = color {
+ let mut out = format!(
+ "\x1b]{};rgb:{:04x}/{:04x}/{:04x}",
+ channel,
+ (r as u16) * 0x0101,
+ (g as u16) * 0x0101,
+ (b as u16) * 0x0101,
+ )
+ .into_bytes();
+ out.extend_from_slice(terminator.as_bytes());
+ out
+ } else {
+ Vec::new()
+ }
+ },
+ HostQuery::PaletteRegister { index, terminator } => {
+ let codes = self.terminal_emulator_color_codes.borrow();
+ if let Some(color) = codes.get(&(*index as usize)) {
+ let mut out = format!("\x1b]4;{};{}", index, color).into_bytes();
+ out.extend_from_slice(terminator.as_bytes());
+ out
+ } else {
+ Vec::new()
+ }
+ },
+ // Should not reach here: ColorPaletteMode short-circuits in
+ // `forward_host_query` before any cache-fallback path runs.
+ HostQuery::ColorPaletteMode => match self.effective_host_terminal_theme_mode() {
+ HostTerminalThemeMode::Dark => b"\x1b[?997;1n".to_vec(),
+ HostTerminalThemeMode::Light => b"\x1b[?997;2n".to_vec(),
+ },
+ }
+ }
+
+ pub fn render(&mut self, plugin_render_assets: Option<Vec<PluginRenderAsset>>) -> Result<()> {
+ // here we schedule the RenderToClients background job which debounces renders every 10ms
+ // rather than actually rendering
+ //
+ // when this job decides to render, it sends back the ScreenInstruction::RenderToClients
+ // message, triggering our render_to_clients method which does the actual rendering
+
+ self.sync_nested_guest_fullscreen_state();
+
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::RenderToClients);
+ if let Some(plugin_render_assets) = plugin_render_assets {
+ let _ = self
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::UnblockCliPipes(plugin_render_assets))
+ .context("failed to unblock input pipe");
+ }
+ Ok(())
+ }
+
+ pub fn render_to_clients(&mut self) -> Result<()> {
+ // this method does the actual rendering and is triggered by a debounced BackgroundJob (see
+ // the render method for more details)
+ let err_context = "failed to render screen";
+
+ // Separate rendering for regular clients and watchers
+ let has_regular_clients = self
+ .connected_clients
+ .borrow()
+ .keys()
+ .any(|id| !self.watcher_clients.contains_key(id));
+ let has_watchers = !self.watcher_clients.is_empty(); // No change needed
+
+ // Track whether non-watcher output was dirty for conditional watcher rendering
+ let non_watcher_output_was_dirty;
+
+ let mut tabs_to_close = vec![];
+
+ // === PHASE 1: Render for regular clients ===
+ if has_regular_clients {
+ let mut output = Output::new(
+ self.sixel_image_store.clone(),
+ self.character_cell_size.clone(),
+ self.styled_underlines,
+ self.osc8_hyperlinks,
+ self.kitty_image_store.clone(),
+ self.kitty_host_capabilities.clone(),
+ self.client_kitty_host_state.clone(),
+ );
+
+ let has_ansi_subscribers = self.pane_render_subscribers.values().any(|s| s.ansi);
+ output.collect_ansi_pane_contents =
+ has_ansi_subscribers || self.plugins_need_ansi_pane_contents;
+
+ for (tab_index, tab) in &mut self.tabs {
+ if tab.has_selectable_tiled_panes() {
+ // Pass None for normal client rendering
+ tab.render(&mut output, None).context(err_context)?;
+ } else if !tab.is_pending() {
+ tabs_to_close.push(*tab_index);
+ }
+ }
+
+ if self.plugins_need_ansi_pane_contents {
+ let all_regular_clients: Vec<ClientId> = self
+ .connected_clients
+ .borrow()
+ .keys()
+ .copied()
+ .filter(|id| !self.watcher_clients.contains_key(id))
+ .collect();
+ if !all_regular_clients.is_empty() {
+ for tab in self.tabs.values() {
+ for pane_id in tab.get_static_and_floating_pane_ids() {
+ if let Some(pane) = tab.get_pane_with_id(pane_id) {
+ match pane_id {
+ PaneId::Terminal(_) => {
+ let contents =
+ pane.pane_contents_with_ansi(None, false, None);
+ output.add_pane_contents_with_ansi(
+ &all_regular_clients,
+ pane_id,
+ contents,
+ );
+ },
+ PaneId::Plugin(_) => {
+ for client_id in &all_regular_clients {
+ let contents = pane.pane_contents_with_ansi(
+ Some(*client_id),
+ false,
+ None,
+ );
+ output.add_pane_contents_with_ansi(
+ &[*client_id],
+ pane_id,
+ contents,
+ );
+ }
+ },
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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
+ .send_to_plugin(PluginInstruction::PaneRenderReport(pane_render_report));
+
+ non_watcher_output_was_dirty = output.is_dirty();
+
+ let mut bell_state_changed = false;
+ let mut has_bell = false;
+
+ if self.visual_bell {
+ let mut panes_to_flash: Vec<PaneId> = vec![];
+ let mut tabs_to_flash: Vec<usize> = vec![];
+
+ let active_tab_ids_snapshot: Vec<usize> =
+ self.active_tab_ids.values().copied().collect();
+
+ for tab in self.tabs.values_mut() {
+ let is_active = active_tab_ids_snapshot.contains(&tab.id);
+ let (new_panes, tab_newly_set) =
+ tab.check_and_handle_bell_notifications(is_active);
+ if !new_panes.is_empty() {
+ panes_to_flash.extend(new_panes);
+ bell_state_changed = true;
+ }
+ if tab_newly_set {
+ tabs_to_flash.push(tab.id);
+ bell_state_changed = true;
+ }
+ }
+
+ has_bell = !panes_to_flash.is_empty() || !tabs_to_flash.is_empty();
+ if !panes_to_flash.is_empty() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::FlashPaneBell(panes_to_flash));
+ }
+ for tab_id in tabs_to_flash {
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::FlashTabBell(tab_id));
+ }
+ } else {
+ // visual_bell disabled: still detect bell for ANSI BEL forwarding only
+ for tab in self.tabs.values_mut() {
+ if tab.check_and_consume_bells_without_visual_notification() {
+ has_bell = true;
+ }
+ }
+ }
+
+ if has_bell {
+ output.add_post_vte_instruction_to_multiple_clients(
+ self.active_tab_ids.keys().copied(),
+ "\u{7}", // ANSI BEL
+ );
+ }
+
+ for (client_id, tab_index) in &self.active_tab_ids {
+ if self.watcher_clients.contains_key(client_id) {
+ continue;
+ }
+ if let Some(tab) = self.tabs.get(tab_index) {
+ output.set_kitty_visible_panes(*client_id, tab.kitty_visible_pane_ids());
+ }
+ }
+
+ if non_watcher_output_was_dirty || has_bell {
+ let mut serialized_output = output.serialize().context(err_context)?;
+ self.mobile_render_gate
+ .blank_gated_clients(&mut serialized_output);
+ if !serialized_output.is_empty() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::Render(Some(serialized_output)))
+ .context(err_context);
+ }
+ }
+
+ let single_pane_names_changed = self.update_single_pane_tab_names();
+ if bell_state_changed || single_pane_names_changed {
+ self.log_and_report_session_state()?;
+ }
+ } 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 ===
+ if has_watchers {
+ if let Some(followed_client_id) = self.followed_client_id {
+ // Create fresh output for watchers
+ let mut watcher_output = Output::new(
+ self.sixel_image_store.clone(),
+ self.character_cell_size.clone(),
+ self.styled_underlines,
+ self.osc8_hyperlinks,
+ self.kitty_image_store.clone(),
+ self.kitty_host_capabilities.clone(),
+ self.client_kitty_host_state.clone(),
+ );
+
+ let focused_tab_index_of_followed_client_id =
+ *self.active_tab_ids.get(&followed_client_id).unwrap_or(&0);
+
+ if let Some(tab) = self
+ .tabs
+ .get_mut(&focused_tab_index_of_followed_client_id)
+ .as_mut()
+ {
+ // Only force render if:
+ // 1. Non-watcher output was dirty, OR
+ // 2. Any watcher needs a forced render (first render or after resize), OR
+ // 3. No non-watcher clients are connected
+ let any_watcher_needs_force_render = self
+ .watcher_clients
+ .values()
+ .any(|state| state.should_force_render());
+ let should_force_render = non_watcher_output_was_dirty
+ || any_watcher_needs_force_render
+ || !has_regular_clients;
+
+ if should_force_render {
+ tab.set_force_render();
+ }
+ tab.render(&mut watcher_output, Some(followed_client_id))
+ .context(err_context)?;
+ }
+
+ // Send the rendered output to all watcher clients
+ if watcher_output.is_dirty() {
+ let mut watcher_render_output: HashMap<ClientId, String> = HashMap::new();
+
+ // For each watcher, clone the output and serialize with size constraints
+ for (watcher_id, watcher_state) in &self.watcher_clients {
+ let mut watcher_specific_output = watcher_output.clone();
+
+ // Serialize this watcher's output with size constraints (cropping and padding handled inside)
+ let mut serialized_output = watcher_specific_output
+ .serialize_with_size(Some(watcher_state.size()), Some(self.size))
+ .context(err_context)?;
+
+ // Get the output for the followed client and map it to this watcher
+ if let Some(followed_output) = serialized_output.remove(&followed_client_id)
+ {
+ watcher_render_output.insert(*watcher_id, followed_output);
+ }
+ }
+
+ // Send to server for delivery to watcher clients
+ if !watcher_render_output.is_empty() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::Render(Some(watcher_render_output)))
+ .context(err_context);
+ }
+
+ // Clear force render flag for all watchers after successful render
+ for watcher_state in self.watcher_clients.values_mut() {
+ watcher_state.clear_force_render();
+ }
+ }
+ }
+ }
+ for tab_index in tabs_to_close {
+ self.close_tab_by_id(tab_index)
+ .context(err_context)
+ .non_fatal();
+ }
+
+ Ok(())
+ }
+
+ /// Returns a mutable reference to this [`Screen`]'s tabs.
+ pub fn get_tabs_mut(&mut self) -> &mut BTreeMap<usize, Tab> {
+ &mut self.tabs
+ }
+
+ pub fn get_tabs(&self) -> &BTreeMap<usize, Tab> {
+ &self.tabs
+ }
+
+ /// Returns an immutable reference to this [`Screen`]'s active [`Tab`].
+ pub fn get_active_tab(&self, client_id: ClientId) -> Result<&Tab> {
+ match self.active_tab_ids.get(&client_id) {
+ Some(tab) => self
+ .tabs
+ .get(tab)
+ .ok_or_else(|| anyhow!("active tab {} does not exist", tab)),
+ None => Err(anyhow!("active tab not found for client {:?}", client_id)),
+ }
+ }
+
+ pub fn get_client_input_mode(&self, client_id: ClientId) -> Option<InputMode> {
+ self.get_active_tab(client_id)
+ .ok()
+ .and_then(|tab| tab.get_client_input_mode(client_id))
+ }
+
+ pub fn get_first_client_id(&self) -> Option<ClientId> {
+ self.active_tab_ids.keys().next().copied()
+ }
+
+ /// Returns an immutable reference to this [`Screen`]'s previous active [`Tab`].
+ /// Consumes the last entry in tab history.
+ pub fn get_previous_tab(&mut self, client_id: ClientId) -> Result<Option<&Tab>> {
+ Ok(
+ match self
+ .tab_history
+ .get_mut(&client_id)
+ .with_context(|| {
+ format!("failed to retrieve tab history for client {client_id:?}")
+ })?
+ .pop()
+ {
+ Some(tab) => self.tabs.get(&tab),
+ None => None,
+ },
+ )
+ }
+
+ /// Returns a mutable reference to this [`Screen`]'s active [`Tab`].
+ pub fn get_active_tab_mut(&mut self, client_id: ClientId) -> Result<&mut Tab> {
+ match self.active_tab_ids.get(&client_id) {
+ Some(tab) => self
+ .tabs
+ .get_mut(tab)
+ .ok_or_else(|| anyhow!("active tab {} does not exist", tab)),
+ None => Err(anyhow!("active tab not found for client {:?}", client_id)),
+ }
+ }
+
+ /// Returns a mutable reference to this [`Screen`]'s indexed [`Tab`].
+ pub fn get_indexed_tab_mut(&mut self, tab_index: usize) -> Option<&mut Tab> {
+ self.get_tabs_mut().get_mut(&tab_index)
+ }
+
+ /// Clear bell notification for the currently focused pane of the given client.
+ /// Also cancels any running flash jobs if applicable.
+ pub fn clear_bell_for_focused_pane(&mut self, client_id: ClientId) {
+ let tab_id_and_pane_id: Option<(usize, PaneId)> =
+ self.get_active_tab_mut(client_id).ok().and_then(|tab| {
+ tab.get_active_pane_id(client_id)
+ .map(|pane_id| (tab.id, pane_id))
+ });
+ if let Some((tab_id, focused_pane_id)) = tab_id_and_pane_id {
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ if tab.panes_with_pending_bell.contains(&focused_pane_id) {
+ let tab_had_bell = tab.tab_has_pending_bell;
+ tab.clear_bell_notification_for_pane(focused_pane_id);
+ let tab_bell_now_cleared = tab_had_bell && !tab.tab_has_pending_bell;
+ let _ =
+ self.bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopFlashPaneBell(vec![
+ focused_pane_id,
+ ]));
+ if tab_bell_now_cleared {
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id));
+ }
+ }
+ }
+ }
+ }
+
+ /// Clear bell notification for a specific pane ID in the given client's active tab.
+ pub fn clear_bell_for_pane_id(&mut self, pane_id: PaneId, client_id: ClientId) {
+ let tab_id: Option<usize> = self.get_active_tab_mut(client_id).ok().map(|tab| tab.id);
+ if let Some(tab_id) = tab_id {
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ if tab.panes_with_pending_bell.contains(&pane_id) {
+ let tab_had_bell = tab.tab_has_pending_bell;
+ tab.clear_bell_notification_for_pane(pane_id);
+ let tab_bell_now_cleared = tab_had_bell && !tab.tab_has_pending_bell;
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopFlashPaneBell(vec![pane_id]));
+ if tab_bell_now_cleared {
+ let _ = self
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id));
+ }
+ }
+ }
+ }
+ }
+
+ pub fn show_floating_panes_in_tab(
+ &mut self,
+ client_id: ClientId,
+ tab_id: Option<usize>,
+ completion: Option<NotificationEnd>,
+ ) -> Result<()> {
+ let tab = match tab_id {
+ Some(id) => self.tabs.get_mut(&id),
+ None => self.get_active_tab_mut(client_id).ok(),
+ };
+ match tab {
+ None => {
+ let mut completion = completion;
+ if let Some(c) = completion.as_mut() {
+ c.set_exit_status(1);
+ c.set_error_message("Tab not found".to_string());
+ }
+ drop(completion);
+ },
+ Some(tab) => tab.show_floating_panes_atomic(completion),
+ }
+ Ok(())
+ }
+
+ pub fn hide_floating_panes_in_tab(
+ &mut self,
+ client_id: ClientId,
+ tab_id: Option<usize>,
+ completion: Option<NotificationEnd>,
+ ) -> Result<()> {
+ let tab = match tab_id {
+ Some(id) => self.tabs.get_mut(&id),
+ None => self.get_active_tab_mut(client_id).ok(),
+ };
+ match tab {
+ None => {
+ let mut completion = completion;
+ if let Some(c) = completion.as_mut() {
+ c.set_exit_status(1);
+ c.set_error_message("Tab not found".to_string());
+ }
+ drop(completion);
+ },
+ Some(tab) => tab.hide_floating_panes_atomic(completion),
+ }
+ Ok(())
+ }
+
+ pub fn are_floating_panes_visible_in_tab(
+ &self,
+ client_id: ClientId,
+ tab_id: Option<usize>,
+ completion: Option<NotificationEnd>,
+ ) -> Result<()> {
+ let tab = match tab_id {
+ Some(id) => self.tabs.get(&id),
+ None => self.get_active_tab(client_id).ok(),
+ };
+ let mut completion = completion;
+ match tab {
+ None => {
+ if let Some(c) = completion.as_mut() {
+ c.set_error_message("Tab not found".to_string());
+ }
+ },
+ Some(tab) => {
+ if tab.are_floating_panes_visible() {
+ if let Some(c) = completion.as_mut() {
+ c.set_stdout_message("true".to_string());
+ }
+ } else {
+ if let Some(c) = completion.as_mut() {
+ c.set_error_message("false".to_string());
+ }
+ }
+ },
+ }
+ drop(completion);
+ Ok(())
+ }
+
+ /// Creates a new [`Tab`] in this [`Screen`]
+ pub fn new_tab(
+ &mut self,
+ tab_id: usize,
+ swap_layouts: (Vec<SwapTiledLayout>, Vec<SwapFloatingLayout>),
+ tab_name: Option<String>,
+ client_id: Option<ClientId>,
+ ) -> Result<()> {
+ let err_context = || format!("failed to create new tab for client {client_id:?}",);
+
+ let client_id = client_id.map(|client_id| {
+ if self.get_active_tab(client_id).is_ok() {
+ client_id
+ } else if let Some(first_client_id) = self.get_first_client_id() {
+ first_client_id
+ } else {
+ client_id
+ }
+ });
+
+ let tab_name = tab_name.unwrap_or_else(|| String::new());
+ let mobile_tab_count = self.mobile_state.mobile_tab_count();
+
+ let position = self.tabs.len();
+ let mut tab = Tab::new(
+ tab_id,
+ position,
+ tab_name,
+ self.size,
+ self.character_cell_size.clone(),
+ self.stacked_resize.clone(),
+ self.stacked_pane_list.clone(),
+ self.sixel_image_store.clone(),
+ self.kitty_image_store.clone(),
+ self.bus
+ .os_input
+ .as_ref()
+ .with_context(err_context)?
+ .clone(),
+ self.bus.senders.clone(),
+ self.max_panes,
+ self.style,
+ self.default_mode_info.clone(),
+ self.pane_frame_style,
+ self.auto_layout,
+ self.connected_clients.clone(),
+ self.session_is_mirrored,
+ client_id,
+ self.copy_options.clone(),
+ self.terminal_emulator_colors.clone(),
+ self.terminal_emulator_color_codes.clone(),
+ swap_layouts,
+ self.default_shell.clone(),
+ self.debug,
+ self.arrow_fonts,
+ self.styled_underlines,
+ self.osc8_hyperlinks,
+ self.explicitly_disable_kitty_keyboard_protocol,
+ self.default_editor.clone(),
+ self.web_clients_allowed,
+ self.web_sharing,
+ self.current_pane_group.clone(),
+ self.currently_marking_pane_group.clone(),
+ self.advanced_mouse_actions,
+ self.mouse_scroll_resize,
+ self.mouse_hover_effects,
+ self.focus_follows_mouse,
+ self.mouse_click_through,
+ self.web_server_ip,
+ self.web_server_port,
+ mobile_tab_count,
+ );
+ for (client_id, mode_info) in &self.mode_info {
+ tab.change_mode_info(mode_info.clone(), *client_id);
+ }
+ for dimmed_client_id in &self.dimmed_clients {
+ tab.set_client_dimmed(*dimmed_client_id, true);
+ }
+ if let Some(aggregate) = self.kitty_host_support_aggregate() {
+ tab.update_kitty_host_support(aggregate);
+ }
+ self.tabs.insert(tab_id, tab);
+ Ok(())
+ }
+ pub fn apply_layout(
+ &mut self,
+ layout: TiledPaneLayout,
+ floating_panes_layout: Vec<FloatingPaneLayout>,
+ new_terminal_ids: Vec<(u32, HoldForCommand)>,
+ new_floating_terminal_ids: Vec<(u32, HoldForCommand)>,
+ new_plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>,
+ tab_id: usize,
+ should_change_client_focus: bool,
+ client_id_and_is_web_client: (ClientId, bool),
+ blocking_terminal: Option<(u32, NotificationEnd)>,
+ ) -> Result<()> {
+ if self.tabs.get(&tab_id).is_none() {
+ // TODO: we should prevent this situation with a UI - eg. cannot close tabs with a
+ // pending state
+ log::error!("Tab with index {tab_id} not found. Cannot apply layout!");
+ return Ok(());
+ }
+ let (client_id, mut is_web_client) = client_id_and_is_web_client;
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ if let Some(connected_client_is_web_client) =
+ self.connected_clients.borrow().get(&client_id)
+ {
+ is_web_client = *connected_client_is_web_client;
+ }
+ client_id
+ } else if let Some(first_client_id) = self.get_first_client_id() {
+ if let Some(first_client_is_web_client) =
+ self.connected_clients.borrow().get(&first_client_id)
+ {
+ is_web_client = *first_client_is_web_client;
+ }
+ first_client_id
+ } else {
+ client_id
+ };
+ let err_context = || format!("failed to apply layout for tab {tab_id:?}",);
+
+ let passthrough_affected_client_ids: Vec<ClientId> = if should_change_client_focus {
+ if self.session_is_mirrored {
+ self.connected_clients
+ .borrow()
+ .iter()
+ .map(|(c, _i)| *c)
+ .collect()
+ } else {
+ vec![client_id]
+ }
+ } else {
+ vec![]
+ };
+ let passthrough_old_focused_panes: Vec<(ClientId, Option<PaneId>)> =
+ passthrough_affected_client_ids
+ .iter()
+ .map(|c| (*c, self.get_active_pane_id(c)))
+ .collect();
+
+ // move the relevant clients out of the current tab and place them in the new one
+ let drained_clients = if should_change_client_focus {
+ if self.session_is_mirrored {
+ let client_mode_infos_in_source_tab = if let Ok(active_tab) =
+ self.get_active_tab_mut(client_id)
+ {
+ let client_mode_infos_in_source_tab = active_tab.drain_connected_clients(None);
+ if active_tab.has_no_connected_clients() {
+ active_tab
+ .visible(false)
+ .with_context(err_context)
+ .non_fatal();
+ }
+ Some(client_mode_infos_in_source_tab)
+ } else {
+ None
+ };
+ let all_connected_clients: Vec<ClientId> = self
+ .connected_clients
+ .borrow()
+ .iter()
+ .map(|(c, _i)| *c)
+ .collect();
+ for client_id in all_connected_clients {
+ self.update_client_tab_focus(client_id, tab_id);
+ }
+ client_mode_infos_in_source_tab
+ } else if let Ok(active_tab) = self.get_active_tab_mut(client_id) {
+ let client_mode_info_in_source_tab =
+ active_tab.drain_connected_clients(Some(vec![client_id]));
+ if active_tab.has_no_connected_clients() {
+ active_tab
+ .visible(false)
+ .with_context(err_context)
+ .non_fatal();
+ }
+ self.update_client_tab_focus(client_id, tab_id);
+ Some(client_mode_info_in_source_tab)
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ if !self.active_tab_ids.contains_key(&client_id) {
+ self.connected_clients
+ .borrow_mut()
+ .insert(client_id, is_web_client);
+ }
+
+ // apply the layout to the new tab
+ self.tabs
+ .get_mut(&tab_id)
+ .context("couldn't find tab with index {tab_id}")
+ .and_then(|tab| {
+ tab.apply_layout(
+ layout,
+ floating_panes_layout,
+ new_terminal_ids,
+ new_floating_terminal_ids,
+ new_plugin_ids,
+ client_id,
+ blocking_terminal,
+ )?;
+ tab.update_input_modes()?;
+
+ if let Some(drained_clients) = drained_clients {
+ tab.visible(true)?;
+ tab.add_multiple_clients(drained_clients)?;
+ }
+ tab.resize_whole_tab(self.size).with_context(err_context)?;
+ tab.set_force_render();
+ Ok(())
+ })
+ .with_context(err_context)?;
+
+ if !self.active_tab_ids.contains_key(&client_id) {
+ // this means this is a new client and we need to add it to our state properly
+ self.add_client(client_id, is_web_client)
+ .with_context(err_context)?;
+ }
+
+ self.recompute_tab_size(tab_id).with_context(err_context)?;
+
+ for (affected_client_id, old_pane_id) in passthrough_old_focused_panes {
+ let new_pane_id = self.get_active_pane_id(&affected_client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ self.report_key_passthrough_state(affected_client_id, old, new);
+ }
+ }
+
+ self.log_and_report_session_state()
+ .and_then(|_| self.render(None))
+ .with_context(err_context)
+ }
+
+ pub fn add_client(&mut self, client_id: ClientId, is_web_client: bool) -> Result<()> {
+ let err_context = |tab_index| {
+ format!("failed to attach client {client_id} to tab with index {tab_index}")
+ };
+
+ // Set followed_client_id to the first regular client if not already set
+ if self.followed_client_id.is_none() && !self.watcher_clients.contains_key(&client_id) {
+ self.followed_client_id = Some(client_id);
+ }
+
+ let mut tab_history = vec![];
+ if let Some((_first_client, first_tab_history)) = self.tab_history.iter().next() {
+ tab_history = first_tab_history.clone();
+ }
+
+ let tab_index = if let Some((_first_client, first_active_tab_index)) =
+ self.active_tab_ids.iter().next()
+ {
+ *first_active_tab_index
+ } else if self.tabs.contains_key(&self.global_last_active_tab_id) {
+ self.global_last_active_tab_id
+ } else if self.tabs.contains_key(&0) {
+ 0
+ } else if let Some(tab_index) = self.tabs.keys().next() {
+ tab_index.to_owned()
+ } else {
+ bail!("Can't find a valid tab to attach client to!");
+ };
+
+ self.active_tab_ids.insert(client_id, tab_index);
+ self.client_kitty_host_state.borrow_mut().remove(&client_id);
+ self.connected_clients
+ .borrow_mut()
+ .insert(client_id, is_web_client);
+ if is_web_client {
+ self.kitty_host_capabilities
+ .borrow_mut()
+ .insert(client_id, false);
+ self.push_kitty_host_support_to_tabs();
+ }
+ self.tab_history.insert(client_id, tab_history);
+ self.tabs
+ .get_mut(&tab_index)
+ .with_context(|| err_context(tab_index))?
+ .add_client(client_id, None)
+ .with_context(|| err_context(tab_index))?;
+ self.recompute_tab_size(tab_index)
+ .with_context(|| err_context(tab_index))?;
+ if !self.nested_ancestry.is_empty() || !self.host_descend_keys.is_empty() {
+ self.update_all_clients_nesting_mode_info();
+ }
+ Ok(())
+ }
+
+ pub fn remove_client(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to remove client {client_id}");
+
+ self.set_client_dimmed(client_id, false, None);
+ let passthrough_panes: Vec<PaneId> = self
+ .nested_guest_choices
+ .iter()
+ .filter(|((choice_client_id, _), choice)| {
+ *choice_client_id == client_id && choice.enters_passthrough()
+ })
+ .map(|((_, choice_pane_id), _)| *choice_pane_id)
+ .collect();
+ for pane_id in passthrough_panes {
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::KeyPassthroughChanged(
+ client_id, pane_id, pane_id, false, None, true,
+ ));
+ }
+ self.nested_guest_choices
+ .retain(|(choice_client_id, _), _| *choice_client_id != client_id);
+ for tab in self.tabs.values_mut() {
+ tab.clear_guest_modal_for_client_on_all_panes(client_id);
+ tab.clear_guest_choice_indicator_for_client_on_all_panes(client_id);
+ }
+
+ // If the followed client disconnected, find the next regular client
+ if Some(client_id) == self.followed_client_id {
+ // Try to find another regular (non-watcher) client
+ self.followed_client_id = self
+ .connected_clients
+ .borrow()
+ .keys()
+ .copied()
+ .find(|id| !self.watcher_clients.contains_key(id) && id != &client_id);
+
+ // If no regular client remains but we have watchers, keep the old followed_client_id
+ // for terminal rendering (plugins will use their last state)
+ if self.followed_client_id.is_none() && !self.watcher_clients.is_empty() {
+ self.followed_client_id = Some(client_id); // Keep the disconnected client's ID
+ }
+ }
+
+ self.mobile_state.forget_client(client_id);
+ self.mobile_render_gate.ungate(client_id);
+
+ let to_recompute = self
+ .mobile_state
+ .clear_fits_owned_by(client_id, &mut self.tabs);
+ for tab_id in to_recompute {
+ self.recompute_tab_size(tab_id).with_context(err_context)?;
+ }
+
+ let mut tabs_to_garbage_collect: Vec<usize> = vec![];
+ for (tab_id, tab) in self.tabs.iter_mut() {
+ tab.remove_client(client_id);
+ if tab.has_no_connected_clients() {
+ tab.visible(false).with_context(err_context)?;
+ }
+ if let Some(set) = tab.visible_to.as_mut() {
+ set.remove(&client_id);
+ if set.is_empty() {
+ tabs_to_garbage_collect.push(*tab_id);
+ }
+ }
+ }
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+ let previously_active_tab_id = self.active_tab_ids.get(&client_id).copied();
+ if let Some(prev_tab_id) = previously_active_tab_id {
+ self.global_last_active_tab_id = prev_tab_id;
+ self.active_tab_ids.remove(&client_id);
+ }
+ if self.tab_history.contains_key(&client_id) {
+ self.tab_history.remove(&client_id);
+ }
+ self.connected_clients.borrow_mut().remove(&client_id);
+ self.client_kitty_host_state.borrow_mut().remove(&client_id);
+ let removed_kitty_capability = self
+ .kitty_host_capabilities
+ .borrow_mut()
+ .remove(&client_id)
+ .is_some();
+ if removed_kitty_capability {
+ self.push_kitty_host_support_to_tabs();
+ }
+ self.client_sizes.remove(&client_id);
+ self.pane_render_subscribers.remove(&client_id);
+ if let Some(prev_tab_id) = previously_active_tab_id {
+ self.recompute_tab_size(prev_tab_id)
+ .with_context(err_context)?;
+ }
+ for tab_id in tabs_to_garbage_collect {
+ if self.tabs.contains_key(&tab_id) {
+ self.close_tab_by_id(tab_id).with_context(err_context)?;
+ }
+ }
+ if self.nested_via_client_id == Some(client_id) {
+ self.nested_via_client_id = None;
+ self.nested_ancestry = vec![];
+ self.host_descended = false;
+ self.host_descend_keys = vec![];
+ self.update_all_clients_nesting_mode_info();
+ self.broadcast_ancestry_update_to_guests();
+ }
+ self.log_and_report_session_state().non_fatal();
+ Ok(())
+ }
+
+ pub fn add_watcher_client(&mut self, client_id: ClientId) -> Result<()> {
+ // Initialize with a default size - will be updated when we receive the actual size
+ let default_size = Size { rows: 24, cols: 80 }; // Reasonable default
+ self.watcher_clients
+ .insert(client_id, WatcherState::new(default_size));
+
+ // Force a full render for the new watcher
+ // This ensures they get complete state, not just delta
+ self.render(None)?;
+
+ Ok(())
+ }
+
+ pub fn remove_watcher_client(&mut self, client_id: ClientId) {
+ self.watcher_clients.remove(&client_id);
+ }
+
+ pub fn set_followed_client(&mut self, client_id: ClientId) -> Result<()> {
+ self.followed_client_id = Some(client_id);
+ // Trigger re-render with new followed client
+ self.render(None)?;
+ Ok(())
+ }
+
+ pub fn set_watcher_size(&mut self, client_id: ClientId, size: Size) {
+ // Update size if this client is a watcher
+ if let Some(watcher_state) = self.watcher_clients.get_mut(&client_id) {
+ watcher_state.set_size(size);
+ watcher_state.set_force_render();
+ }
+ }
+
+ pub fn generate_and_report_tab_state(&mut self) -> Result<Vec<TabInfo>> {
+ let mut plugin_updates = vec![];
+ let mut tab_infos_for_screen_state = BTreeMap::new();
+ for tab in self.tabs.values() {
+ let mut all_focused_clients: Vec<ClientId> = self
+ .active_tab_ids
+ .iter()
+ .filter(|(_c_id, tab_position)| **tab_position == tab.id)
+ .map(|(c_id, _)| c_id)
+ .copied()
+ .collect();
+ for s_id in tab.shadow_focus_clients() {
+ if !all_focused_clients.contains(&s_id) {
+ all_focused_clients.push(s_id);
+ }
+ }
+ let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
+ let tab_viewport = tab.get_viewport();
+ let tab_display_area = tab.get_display_area();
+ let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count();
+ let selectable_floating_panes_count = tab.get_selectable_floating_panes_count();
+ let tab_info_for_screen = TabInfo {
+ position: tab.position,
+ name: tab.name.clone(),
+ active: self.active_tab_ids.values().any(|i| i == &tab.id),
+ panes_to_hide: tab.panes_to_hide_count(),
+ is_fullscreen_active: tab.is_fullscreen_active(),
+ is_sync_panes_active: tab.is_sync_panes_active(),
+ are_floating_panes_visible: tab.are_floating_panes_visible(),
+ other_focused_clients: all_focused_clients,
+ active_swap_layout_name,
+ is_swap_layout_dirty,
+ viewport_rows: tab_viewport.rows,
+ viewport_columns: tab_viewport.cols,
+ display_area_rows: tab_display_area.rows,
+ display_area_columns: tab_display_area.cols,
+ selectable_tiled_panes_count,
+ selectable_floating_panes_count,
+ tab_id: tab.id,
+ has_bell_notification: tab.tab_has_pending_bell
+ && !self.active_tab_ids.values().any(|i| i == &tab.id),
+ is_flashing_bell: tab.tab_bell_flash
+ && !self.active_tab_ids.values().any(|i| i == &tab.id),
+ };
+ tab_infos_for_screen_state.insert(tab.position, tab_info_for_screen);
+ }
+ for (client_id, active_tab_index) in self.active_tab_ids.iter() {
+ let mut plugin_tab_updates = vec![];
+ for tab in self.tabs.values() {
+ if let Some(set) = &tab.visible_to {
+ if !set.contains(client_id) {
+ continue;
+ }
+ }
+ let other_focused_clients: Vec<ClientId> = if self.session_is_mirrored {
+ vec![]
+ } else {
+ let mut clients: Vec<ClientId> = self
+ .active_tab_ids
+ .iter()
+ .filter(|(c_id, tab_position)| {
+ **tab_position == tab.id && *c_id != client_id
+ })
+ .map(|(c_id, _)| c_id)
+ .copied()
+ .collect();
+ for s_id in tab.shadow_focus_clients() {
+ if s_id != *client_id && !clients.contains(&s_id) {
+ clients.push(s_id);
+ }
+ }
+ clients
+ };
+ let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
+ let tab_viewport = tab.get_viewport();
+ let tab_display_area = tab.get_display_area();
+ let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count();
+ let selectable_floating_panes_count = tab.get_selectable_floating_panes_count();
+ let tab_info_for_plugins = TabInfo {
+ position: tab.position,
+ name: tab
+ .single_pane_tab_name()
+ .unwrap_or_else(|| tab.name.clone()),
+ active: *active_tab_index == tab.id,
+ panes_to_hide: tab.panes_to_hide_count(),
+ is_fullscreen_active: tab.is_fullscreen_active(),
+ is_sync_panes_active: tab.is_sync_panes_active(),
+ are_floating_panes_visible: tab.are_floating_panes_visible(),
+ other_focused_clients,
+ active_swap_layout_name,
+ is_swap_layout_dirty,
+ viewport_rows: tab_viewport.rows,
+ viewport_columns: tab_viewport.cols,
+ display_area_rows: tab_display_area.rows,
+ display_area_columns: tab_display_area.cols,
+ selectable_tiled_panes_count,
+ selectable_floating_panes_count,
+ tab_id: tab.id,
+ has_bell_notification: tab.tab_has_pending_bell && *active_tab_index != tab.id,
+ is_flashing_bell: tab.tab_bell_flash && *active_tab_index != tab.id,
+ };
+ plugin_tab_updates.push(tab_info_for_plugins);
+ }
+ plugin_tab_updates.sort_by(|a, b| a.position.cmp(&b.position));
+ let target_plugin_ids = self.targeted_plugin_ids(*client_id, EventType::TabUpdate);
+ for plugin_id in target_plugin_ids {
+ plugin_updates.push((
+ Some(plugin_id),
+ Some(*client_id),
+ Event::TabUpdate(plugin_tab_updates.clone()),
+ ));
+ }
+ }
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(plugin_updates))
+ .context("failed to update tabs")?;
+ Ok(tab_infos_for_screen_state.values().cloned().collect())
+ }
+ fn generate_and_report_pane_state(&mut self) -> Result<PaneManifest> {
+ let mut pane_manifest = PaneManifest::default();
+ for tab in self.tabs.values() {
+ pane_manifest.panes.insert(tab.position, tab.pane_infos());
+ }
+ let mut plugin_updates = vec![];
+ let client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect();
+ for client_id in client_ids {
+ let target_plugin_ids = self.targeted_plugin_ids(client_id, EventType::PaneUpdate);
+ for plugin_id in target_plugin_ids {
+ plugin_updates.push((
+ Some(plugin_id),
+ Some(client_id),
+ Event::PaneUpdate(pane_manifest.clone()),
+ ));
+ }
+ }
+ if !plugin_updates.is_empty() {
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(plugin_updates))
+ .context("failed to update pane state")?;
+ }
+
+ Ok(pane_manifest)
+ }
+
+ fn collect_pane_list(&self, show_all: bool) -> Result<ListPanesResponse> {
+ fn should_include_pane(pane_info: &PaneInfo, show_all: bool) -> bool {
+ pane_info.is_selectable || show_all
+ }
+
+ fn create_pane_list_entry(pane_info: PaneInfo, tab: &crate::tab::Tab) -> PaneListEntry {
+ PaneListEntry {
+ pane_info,
+ tab_id: tab.id,
+ tab_position: tab.position,
+ tab_name: tab.name.clone(),
+ pane_command: None,
+ pane_cwd: None,
+ }
+ }
+
+ fn sort_panes_by_tab_and_type(pane_entries: &mut [PaneListEntry]) {
+ pane_entries.sort_by_key(|e| (e.tab_position, !e.pane_info.is_plugin, e.pane_info.id));
+ }
+
+ let mut pane_entries = Vec::new();
+
+ for tab in self.tabs.values() {
+ let pane_infos = tab.pane_infos();
+
+ for pane_info in pane_infos {
+ if should_include_pane(&pane_info, show_all) {
+ pane_entries.push(create_pane_list_entry(pane_info, tab));
+ }
+ }
+ }
+
+ sort_panes_by_tab_and_type(&mut pane_entries);
+ Ok(pane_entries)
+ }
+
+ fn collect_tab_list(&self, _client_id: ClientId) -> Result<ListTabsResponse> {
+ let mut tab_infos = Vec::new();
+
+ for tab in self.tabs.values() {
+ if let Some(tab_info) = self.get_tab_info(tab.id) {
+ tab_infos.push(tab_info);
+ }
+ }
+
+ // Sort by position (display order)
+ tab_infos.sort_by_key(|t| t.position);
+
+ Ok(tab_infos)
+ }
+
+ fn get_current_tab_info(&self, client_id: ClientId) -> Result<Option<TabInfo>> {
+ match self.active_tab_ids.get(&client_id) {
+ Some(active_tab_id) => Ok(self.get_tab_info(*active_tab_id)),
+ None => Ok(None),
+ }
+ }
+
+ fn update_single_pane_tab_names(&mut self) -> bool {
+ let current: HashMap<usize, Option<String>> = self
+ .tabs
+ .values()
+ .map(|tab| (tab.id, tab.single_pane_tab_name()))
+ .collect();
+ if current != self.last_single_pane_tab_names {
+ self.last_single_pane_tab_names = current;
+ true
+ } else {
+ false
+ }
+ }
+ fn log_and_report_session_state(&mut self) -> Result<()> {
+ let err_context = || format!("Failed to log and report session state");
+
+ self.update_active_pane_ids();
+ // generate own session info
+ let pane_manifest = self.generate_and_report_pane_state()?;
+ let tab_infos = self.generate_and_report_tab_state()?;
+
+ // Lazy-load layouts on first call if cache is empty
+ // After that, cache is updated by watcher via UpdateAvailableLayouts instruction
+ if self.cached_layouts.is_empty() {
+ #[cfg(not(test))]
+ {
+ let (layouts, errors) = Layout::list_available_layouts(
+ self.layout_dir.clone(),
+ &self.default_layout_name,
+ );
+ self.cached_layouts = layouts;
+ self.cached_layout_errors = errors;
+ }
+ #[cfg(test)]
+ {
+ self.cached_layouts = vec![];
+ self.cached_layout_errors = vec![];
+ }
+ }
+ let available_layouts = self.cached_layouts.clone();
+ let creation_time = {
+ let sock_path = ZELLIJ_SOCK_DIR.join(&self.session_name);
+ std::fs::metadata(&sock_path)
+ .ok()
+ .and_then(|f| f.created().ok().or_else(|| f.modified().ok()))
+ .and_then(|d| d.elapsed().ok())
+ .map(|d| Duration::from_secs(d.as_secs()))
+ .unwrap_or_default()
+ };
+ let session_info = SessionInfo {
+ name: self.session_name.clone(),
+ tabs: tab_infos,
+ panes: pane_manifest,
+ connected_clients: self.active_tab_ids.keys().len(),
+ is_current_session: true,
+ available_layouts,
+ web_clients_allowed: self.web_sharing.web_clients_allowed(),
+ web_client_count: self
+ .connected_clients
+ .borrow()
+ .iter()
+ .filter(|(_client_id, is_web_client)| **is_web_client)
+ .count(),
+ plugins: Default::default(), // these are filled in by the wasm thread
+ tab_history: self.tab_history.clone(),
+ pane_history: self
+ .pane_history
+ .iter()
+ .map(|(k, v)| (*k, v.iter().map(|v| (*v).into()).collect()))
+ .collect(),
+ creation_time,
+ };
+ self.bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::ReportSessionInfo(
+ self.session_name.to_owned(),
+ session_info.clone(),
+ ))
+ .with_context(err_context)?;
+
+ self.peer_sessions_cache
+ .insert(self.session_name.clone(), session_info);
+ let mut live_sessions: Vec<SessionInfo> =
+ self.peer_sessions_cache.values().cloned().collect();
+ for info in live_sessions.iter_mut() {
+ info.is_current_session = info.name == self.session_name;
+ }
+ let resurrectable_sessions: Vec<(String, Duration)> = self
+ .resurrectable_sessions_cache
+ .iter()
+ .map(|(n, d)| (n.clone(), *d))
+ .collect();
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(vec![(
+ None,
+ None,
+ Event::SessionUpdate(live_sessions, resurrectable_sessions),
+ )]))
+ .with_context(err_context)?;
+
+ self.bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::QueryZellijWebServerStatus)
+ .with_context(err_context)?;
+ Ok(())
+ }
+ fn dump_layout_to_hd(&mut self) -> Result<()> {
+ let err_context = || format!("Failed to log and report session state");
+ let session_layout_metadata =
+ self.get_layout_metadata(Some(self.default_shell.clone()), None);
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::LogLayoutToHd(session_layout_metadata))
+ .with_context(err_context)?;
+
+ Ok(())
+ }
+ pub fn update_session_infos(
+ &mut self,
+ new_session_infos: BTreeMap<String, SessionInfo>,
+ resurrectable_sessions: BTreeMap<String, Duration>,
+ ) -> Result<()> {
+ self.peer_sessions_cache = new_session_infos;
+ self.resurrectable_sessions_cache = resurrectable_sessions;
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(vec![(
+ None,
+ None,
+ Event::SessionUpdate(
+ self.peer_sessions_cache.values().cloned().collect(),
+ self.resurrectable_sessions_cache
+ .iter()
+ .map(|(n, c)| (n.clone(), c.clone()))
+ .collect(),
+ ),
+ )]))
+ .context("failed to update session info")?;
+ Ok(())
+ }
+
+ pub fn update_available_layouts(
+ &mut self,
+ layouts: Vec<LayoutInfo>,
+ errors: Vec<LayoutWithError>,
+ ) {
+ self.cached_layouts = layouts;
+ self.cached_layout_errors = errors;
+ }
+
+ pub fn update_active_tab_name(&mut self, buf: Vec<u8>, client_id: ClientId) -> Result<()> {
+ let err_context =
+ || format!("failed to update active tabs name for client id: {client_id:?}");
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+
+ match client_id {
+ Some(client_id) => {
+ let s = str::from_utf8(&buf)
+ .with_context(|| format!("failed to construct tab name from buf: {buf:?}"))
+ .with_context(err_context)?;
+ match self.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ match s {
+ "\0" => {
+ active_tab.name = String::new();
+ },
+ "\u{007F}" | "\u{0008}" => {
+ // delete and backspace keys
+ active_tab.name.pop();
+ },
+ c => {
+ active_tab
+ .name
+ .push_str(&clean_string_from_control_and_linebreak(c));
+ },
+ }
+ self.log_and_report_session_state()
+ .with_context(err_context)
+ },
+ Err(err) => {
+ Err::<(), _>(err).with_context(err_context).non_fatal();
+ Ok(())
+ },
+ }
+ },
+ None => Ok(()),
+ }
+ }
+ pub fn undo_active_rename_tab(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to undo active tab rename for client {}", client_id);
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+ match client_id {
+ Some(client_id) => {
+ match self.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ if active_tab.name != active_tab.prev_name {
+ active_tab.name = active_tab.prev_name.clone();
+ self.log_and_report_session_state()
+ .context("failed to undo renaming of active tab")?;
+ }
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ };
+ Ok(())
+ },
+ None => Ok(()),
+ }
+ }
+
+ pub fn move_active_tab_to_left(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || "Failed to move active tab left";
+ if self.tabs.len() < 2 {
+ debug!("cannot move tab to left: only one tab exists");
+ return Ok(());
+ }
+ let Some(client_id) = self.client_id(client_id) else {
+ return Ok(());
+ };
+
+ match self.get_active_tab(client_id) {
+ Ok(active_tab) => {
+ let active_tab_pos = active_tab.position;
+ if active_tab_pos == 0 {
+ self.rotate_tab_positions_left();
+ } else {
+ self.switch_tabs(active_tab_pos, active_tab_pos.saturating_sub(1));
+ }
+ self.log_and_report_session_state()
+ .context("failed to move tab to left")?;
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ }
+ Ok(())
+ }
+
+ fn client_id(&mut self, client_id: ClientId) -> Option<u16> {
+ if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ }
+ }
+
+ /// Switches tabs at two positions, swapping their display order.
+ ///
+ /// # Arguments
+ /// * `active_tab_pos` - Current position of active tab (0-based)
+ /// * `other_tab_pos` - Position to swap with (0-based)
+ ///
+ /// NOTE: this expects positions rather than IDs (see distinction at top of file)
+ fn switch_tabs(&mut self, active_tab_pos: usize, other_tab_pos: usize) {
+ let Some(active_tab_id) = self
+ .tabs
+ .values()
+ .find(|t| t.position == active_tab_pos)
+ .map(|t| t.id)
+ else {
+ log::error!("Failed to find active tab at position: {}", active_tab_pos);
+ return;
+ };
+ let Some(other_tab_id) = self
+ .tabs
+ .values()
+ .find(|t| t.position == other_tab_pos)
+ .map(|t| t.id)
+ else {
+ log::error!(
+ "Failed to find tab to switch to at position: {}",
+ other_tab_pos
+ );
+ return;
+ };
+
+ if !self.tabs.contains_key(&active_tab_id) || !self.tabs.contains_key(&other_tab_id) {
+ warn!(
+ "failed to switch tabs: index {} or {} not found in {:?}",
+ active_tab_id,
+ other_tab_id,
+ self.tabs.keys()
+ );
+ return;
+ }
+
+ // NOTE: Can `expect` here, because we checked that the keys exist above
+ let mut active_tab = self
+ .tabs
+ .remove(&active_tab_id)
+ .expect("active tab not found");
+ let mut other_tab = self
+ .tabs
+ .remove(&other_tab_id)
+ .expect("other tab not found");
+
+ std::mem::swap(&mut active_tab.position, &mut other_tab.position);
+
+ self.tabs.insert(active_tab_id, active_tab);
+ self.tabs.insert(other_tab_id, other_tab);
+ }
+
+ fn rotate_tab_positions_left(&mut self) {
+ let last_position = self.tabs.len().saturating_sub(1);
+ for tab in self.tabs.values_mut() {
+ tab.position = if tab.position == 0 {
+ last_position
+ } else {
+ tab.position.saturating_sub(1)
+ };
+ }
+ }
+
+ fn rotate_tab_positions_right(&mut self) {
+ let num_tabs = self.tabs.len();
+ for tab in self.tabs.values_mut() {
+ tab.position = (tab.position + 1) % num_tabs;
+ }
+ }
+
+ pub fn move_active_tab_to_right(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || "Failed to move active tab right ";
+ if self.tabs.len() < 2 {
+ debug!("cannot move tab to right: only one tab exists");
+ return Ok(());
+ }
+ let Some(client_id) = self.client_id(client_id) else {
+ return Ok(());
+ };
+
+ match self.get_active_tab(client_id) {
+ Ok(active_tab) => {
+ let active_tab_pos = active_tab.position;
+ if active_tab_pos == self.tabs.len().saturating_sub(1) {
+ self.rotate_tab_positions_right();
+ } else {
+ self.switch_tabs(active_tab_pos, active_tab_pos + 1);
+ }
+ self.log_and_report_session_state()
+ .context("failed to move tab to the right")?;
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ }
+ Ok(())
+ }
+
+ pub fn move_tab_by_id(&mut self, tab_id: usize, direction: Direction) -> Result<()> {
+ if self.tabs.len() < 2 {
+ return Ok(());
+ }
+ if let Some(tab) = self.tabs.get(&tab_id) {
+ let tab_pos = tab.position;
+ let num_tabs = self.tabs.len();
+ match direction {
+ Direction::Left => {
+ if tab_pos == 0 {
+ self.rotate_tab_positions_left();
+ } else {
+ self.switch_tabs(tab_pos, tab_pos.saturating_sub(1));
+ }
+ },
+ Direction::Right => {
+ if tab_pos == num_tabs.saturating_sub(1) {
+ self.rotate_tab_positions_right();
+ } else {
+ self.switch_tabs(tab_pos, tab_pos + 1);
+ }
+ },
+ _ => return Ok(()),
+ }
+ self.log_and_report_session_state()?;
+ } else {
+ log::error!("Tab with id {} not found", tab_id);
+ }
+ Ok(())
+ }
+
+ pub fn change_mode(
+ &mut self,
+ new_mode: InputMode,
+ base_mode: Option<InputMode>,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let mut mode_info = self
+ .mode_info
+ .get(&client_id)
+ .cloned()
+ .unwrap_or_else(|| self.default_mode_info.clone());
+ let previous_mode = mode_info.mode;
+ mode_info.mode = new_mode;
+ mode_info.base_mode = base_mode;
+ if mode_info.session_name.as_ref() != Some(&self.session_name) {
+ mode_info.session_name = Some(self.session_name.clone());
+ }
+
+ let err_context = || {
+ format!(
+ "failed to change from mode '{:?}' to mode '{:?}' for client {client_id}",
+ previous_mode, new_mode
+ )
+ };
+
+ // If we leave the Search-related modes, we need to clear all previous searches
+ let search_related_modes = [InputMode::EnterSearch, InputMode::Search, InputMode::Scroll];
+ if search_related_modes.contains(&previous_mode)
+ && !search_related_modes.contains(&mode_info.mode)
+ {
+ active_tab!(self, client_id, |tab: &mut Tab| tab.clear_search(client_id));
+ }
+
+ if previous_mode == InputMode::Scroll
+ && (mode_info.mode == InputMode::Normal || mode_info.mode == InputMode::Locked)
+ {
+ if let Ok(active_tab) = self.get_active_tab_mut(client_id) {
+ active_tab
+ .clear_active_terminal_scroll(client_id)
+ .with_context(err_context)?;
+ }
+ }
+
+ if mode_info.mode == InputMode::RenameTab {
+ if let Ok(active_tab) = self.get_active_tab_mut(client_id) {
+ active_tab.prev_name = active_tab.name.clone();
+ }
+ }
+
+ if mode_info.mode == InputMode::RenamePane {
+ if let Ok(active_tab) = self.get_active_tab_mut(client_id) {
+ if let Some(active_pane) =
+ active_tab.get_active_pane_or_floating_pane_mut(client_id)
+ {
+ active_pane.store_pane_name();
+ }
+ }
+ }
+
+ self.style = mode_info.style;
+ self.mode_info.insert(client_id, mode_info.clone());
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info.clone(), client_id);
+ tab.mark_active_pane_for_rerender(client_id);
+ tab.update_input_modes()?;
+ }
+ // Notify background plugins subscribed to ModeUpdate
+ let mut bg_updates = vec![];
+ for ((bg_pid, bg_cid), subs) in &self.background_plugin_subscriptions {
+ if subs.contains(&EventType::ModeUpdate) && *bg_cid == client_id {
+ bg_updates.push((
+ Some(*bg_pid),
+ Some(*bg_cid),
+ Event::ModeUpdate(mode_info.clone()),
+ ));
+ }
+ }
+ if !bg_updates.is_empty() {
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(bg_updates))
+ .context("failed to update background plugins with mode info")?;
+ }
+ Ok(())
+ }
+ pub fn change_mode_for_all_clients(
+ &mut self,
+ new_mode: InputMode,
+ base_mode: Option<InputMode>,
+ ) -> Result<()> {
+ let err_context = || {
+ format!(
+ "failed to change input mode to {:?} for all clients",
+ new_mode
+ )
+ };
+
+ let connected_client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect();
+ for client_id in connected_client_ids {
+ self.change_mode(new_mode, base_mode, client_id)
+ .with_context(err_context)?;
+ }
+ Ok(())
+ }
+ /// Collect plugin IDs that should receive a broadcast event for a given client.
+ /// Returns plugin IDs from the client's active tab plus background plugins
+ /// subscribed to the given event type.
+ fn targeted_plugin_ids(&self, client_id: ClientId, event_type: EventType) -> Vec<PluginId> {
+ let mut plugin_ids = Vec::new();
+ // Active-tab plugins
+ if let Some(active_tab_id) = self.active_tab_ids.get(&client_id) {
+ if let Some(tab) = self.tabs.get(active_tab_id) {
+ plugin_ids.extend(tab.get_plugin_ids());
+ }
+ }
+ // Background plugins subscribed to this event type
+ for ((bg_pid, bg_cid), subs) in &self.background_plugin_subscriptions {
+ if subs.contains(&event_type) && *bg_cid == client_id {
+ if !plugin_ids.contains(bg_pid) {
+ plugin_ids.push(*bg_pid);
+ }
+ }
+ }
+ plugin_ids
+ }
+ /// Broadcast a ModeUpdate event to active-tab plugins and subscribed background plugins.
+ pub fn broadcast_mode_update(
+ &mut self,
+ mode_info: ModeInfo,
+ target_client_id: Option<ClientId>,
+ ) -> Result<()> {
+ let mut plugin_updates = vec![];
+ let client_ids: Vec<ClientId> = if let Some(cid) = target_client_id {
+ vec![cid]
+ } else {
+ self.active_tab_ids.keys().copied().collect()
+ };
+ for client_id in client_ids {
+ let plugin_ids = self.targeted_plugin_ids(client_id, EventType::ModeUpdate);
+ for plugin_id in plugin_ids {
+ plugin_updates.push((
+ Some(plugin_id),
+ Some(client_id),
+ Event::ModeUpdate(mode_info.clone()),
+ ));
+ }
+ }
+ if !plugin_updates.is_empty() {
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(plugin_updates))
+ .context("failed to broadcast mode update")?;
+ }
+ Ok(())
+ }
+ pub fn move_focus_left_or_previous_tab(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || {
+ format!(
+ "failed to move focus left or to previous tab for client {}",
+ client_id
+ )
+ };
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+ if let Some(client_id) = client_id {
+ match self.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ active_tab
+ .move_focus_left(client_id)
+ .and_then(|success| {
+ if !success {
+ if self.tabs.len() == 1 {
+ self.bubble_focus_to_host(client_id, Direction::Left);
+ Ok(())
+ } else {
+ self.switch_tab_prev(Some(Direction::Left), true, client_id)
+ .context("failed to move focus to previous tab")
+ }
+ } else {
+ Ok(())
+ }
+ })
+ .with_context(err_context)?;
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ };
+ }
+ self.log_and_report_session_state()
+ .with_context(err_context)?;
+ Ok(())
+ }
+ pub fn move_focus_right_or_next_tab(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || {
+ format!(
+ "failed to move focus right or to next tab for client {}",
+ client_id
+ )
+ };
+
+ let client_id = if self.get_active_tab(client_id).is_ok() {
+ Some(client_id)
+ } else {
+ self.get_first_client_id()
+ };
+
+ if let Some(client_id) = client_id {
+ match self.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ active_tab
+ .move_focus_right(client_id)
+ .and_then(|success| {
+ if !success {
+ if self.tabs.len() == 1 {
+ self.bubble_focus_to_host(client_id, Direction::Right);
+ Ok(())
+ } else {
+ self.switch_tab_next(Some(Direction::Right), true, client_id)
+ .context("failed to move focus to next tab")
+ }
+ } else {
+ Ok(())
+ }
+ })
+ .with_context(err_context)?;
+ },
+ Err(err) => Err::<(), _>(err).with_context(err_context).non_fatal(),
+ };
+ }
+ self.log_and_report_session_state()
+ .with_context(err_context)?;
+ Ok(())
+ }
+ pub fn toggle_tab(&mut self, client_id: ClientId) -> Result<()> {
+ let tab = self
+ .get_previous_tab(client_id)
+ .context("failed to toggle tabs")?;
+ if let Some(t) = tab {
+ let position = t.position;
+ self.go_to_tab(position + 1, client_id)
+ .context("failed to toggle tabs")?;
+ };
+
+ self.log_and_report_session_state()
+ .context("failed to toggle tabs")?;
+ self.render(None)
+ }
+
+ pub fn focus_plugin_pane(
+ &mut self,
+ run_plugin: &RunPluginOrAlias,
+ should_float: bool,
+ move_to_focused_tab: bool,
+ should_be_in_place: bool,
+ client_id: ClientId,
+ completion_tx: &mut Option<NotificationEnd>,
+ ) -> Result<bool> {
+ // true => found and focused, false => not
+ let err_context = || format!("failed to focus_plugin_pane");
+ let mut tab_index_and_plugin_pane_id = None;
+ let mut plugin_pane_to_move_to_active_tab = None;
+ let focused_tab_index = *self.active_tab_ids.get(&client_id).unwrap_or(&0);
+ let all_tabs = self.get_tabs_mut();
+ for (tab_index, tab) in all_tabs.iter_mut() {
+ if let Some(plugin_pane_id) = tab.find_plugin(&run_plugin) {
+ tab_index_and_plugin_pane_id = Some((*tab_index, plugin_pane_id));
+ if move_to_focused_tab && focused_tab_index != *tab_index {
+ plugin_pane_to_move_to_active_tab = tab.extract_pane(plugin_pane_id, true);
+ }
+
+ break;
+ }
+ }
+ if let Some(plugin_pane_to_move_to_active_tab) = plugin_pane_to_move_to_active_tab.take() {
+ let pane_id = plugin_pane_to_move_to_active_tab.pid();
+ let new_active_tab = self.get_active_tab_mut(client_id)?;
+
+ if should_float {
+ new_active_tab.show_floating_panes();
+ new_active_tab.add_floating_pane(
+ plugin_pane_to_move_to_active_tab,
+ pane_id,
+ None,
+ true,
+ Some(client_id),
+ )?;
+ // TODO: also should_be_in_place
+ } else {
+ new_active_tab.hide_floating_panes();
+ new_active_tab.add_tiled_pane(
+ plugin_pane_to_move_to_active_tab,
+ pane_id,
+ false,
+ Some(client_id),
+ )?;
+ }
+ // Set affected pane ID for CLI client output
+ if let Some(ref mut completion) = completion_tx {
+ completion.set_affected_pane_id(pane_id);
+ }
+ return Ok(true);
+ }
+ match tab_index_and_plugin_pane_id {
+ Some((tab_index, plugin_pane_id)) => {
+ self.go_to_tab(tab_index + 1, client_id)?;
+ self.tabs
+ .get_mut(&tab_index)
+ .with_context(err_context)?
+ .focus_pane_with_id(plugin_pane_id, should_float, should_be_in_place, client_id)
+ .context("failed to focus plugin pane")?;
+ self.log_and_report_session_state()
+ .with_context(err_context)?;
+ // Set affected pane ID for CLI client output
+ if let Some(ref mut completion) = completion_tx {
+ completion.set_affected_pane_id(plugin_pane_id);
+ }
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
+ pub fn focus_pane_with_id(
+ &mut self,
+ pane_id: PaneId,
+ should_float_if_hidden: bool,
+ should_open_in_place: bool,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let err_context = || format!("failed to focus_plugin_pane");
+ let tab_index = self
+ .tabs
+ .iter()
+ .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id))
+ .map(|(_tab_index, tab)| tab.position);
+ match tab_index {
+ Some(tab_index) => {
+ self.go_to_tab(tab_index + 1, client_id)?;
+ self.tabs
+ .iter_mut()
+ .find(|(_, t)| t.position == tab_index)
+ .map(|(_, t)| {
+ t.focus_pane_with_id(
+ pane_id,
+ should_float_if_hidden,
+ should_open_in_place,
+ client_id,
+ )
+ })
+ .with_context(err_context)
+ .non_fatal();
+ },
+ None => {
+ log::error!("Could not find pane with id: {:?}", pane_id);
+ },
+ };
+ Ok(())
+ }
+ pub fn rerun_command_pane_with_id(
+ &mut self,
+ terminal_pane_id: u32,
+ completion_tx: Option<NotificationEnd>,
+ ) {
+ let mut found = false;
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&PaneId::Terminal(terminal_pane_id)) {
+ tab.rerun_terminal_pane_with_id(terminal_pane_id, completion_tx);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!(
+ "Failed to find terminal pane with id: {} to run",
+ terminal_pane_id
+ );
+ }
+ }
+ pub fn resize_pane_with_id(&mut self, resize: ResizeStrategy, pane_id: PaneId) {
+ let mut found = false;
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.resize_pane_with_id(resize, pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Failed to find pane with id: {:?} to resize", pane_id);
+ }
+ }
+ pub fn break_pane(
+ &mut self,
+ default_shell: Option<TerminalAction>,
+ default_layout: Box<Layout>,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let err_context = || "failed break pane out of tab".to_string();
+ let active_tab = self.get_active_tab_mut(client_id)?;
+ let active_pane_id = active_tab
+ .get_active_pane_id(client_id)
+ .with_context(err_context)?;
+ if active_tab.get_selectable_tiled_panes_count() > 1
+ || active_tab.get_visible_selectable_floating_panes_count() > 0
+ || active_tab.pane_is_stack_list_member(&active_pane_id)
+ {
+ let active_pane = active_tab
+ .extract_pane(active_pane_id, false)
+ .with_context(err_context)?;
+ let active_pane_run_instruction = active_pane.invoked_with().clone();
+ let tab_index = self.get_new_tab_id();
+ let swap_layouts = (
+ default_layout.swap_tiled_layouts.clone(),
+ default_layout.swap_floating_layouts.clone(),
+ );
+ self.new_tab(tab_index, swap_layouts, None, Some(client_id))?;
+ let tab = self.tabs.get_mut(&tab_index).with_context(err_context)?;
+ let (mut tiled_panes_layout, floating_panes_layout) = default_layout.new_tab();
+ let without_relayout = true;
+ tab.add_tiled_pane(
+ active_pane,
+ active_pane_id,
+ without_relayout,
+ Some(client_id),
+ )?;
+ tiled_panes_layout.ignore_run_instruction(active_pane_run_instruction.clone());
+ let should_change_focus_to_new_tab = true;
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+ self.bus.senders.send_to_plugin(PluginInstruction::NewTab(
+ None,
+ default_shell,
+ Some(tiled_panes_layout),
+ floating_panes_layout,
+ tab_index,
+ None, // initial_panes
+ false, // block_on_first_terminal
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ None,
+ ))?;
+ } else {
+ self.bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::DisplayPaneError(
+ vec![active_pane_id],
+ "Cannot break single pane out!".into(),
+ ))
+ .with_context(err_context)?;
+ }
+ Ok(())
+ }
+ pub fn break_multiple_panes_to_new_tab(
+ &mut self,
+ pane_ids: Vec<PaneId>,
+ default_shell: Option<TerminalAction>,
+ should_change_focus_to_new_tab: bool,
+ new_tab_name: Option<String>,
+ client_id: ClientId,
+ ) -> Result<usize> {
+ let err_context = || "failed break multiple panes to a new tab".to_string();
+
+ let all_tabs = self.get_tabs_mut();
+ let mut extracted_panes = vec![];
+ for pane_id in pane_ids {
+ for tab in all_tabs.values_mut() {
+ // here we pass None instead of the client_id we have because we do not need to
+ // necessarily trigger a relayout for this tab
+ if let Some(pane) = tab.extract_pane(pane_id, true).take() {
+ extracted_panes.push(pane);
+ break;
+ }
+ }
+ }
+
+ let (mut tiled_panes_layout, floating_panes_layout) = self.default_layout.new_tab();
+ let tab_index = self.get_new_tab_id();
+ let swap_layouts = (
+ self.default_layout.swap_tiled_layouts.clone(),
+ self.default_layout.swap_floating_layouts.clone(),
+ );
+ if should_change_focus_to_new_tab {
+ self.new_tab(tab_index, swap_layouts, None, Some(client_id))?;
+ } else {
+ self.new_tab(tab_index, swap_layouts, None, None)?;
+ }
+ let tab = self.tabs.get_mut(&tab_index).with_context(err_context)?;
+ if let Some(new_tab_name) = new_tab_name {
+ tab.name = new_tab_name.clone();
+ }
+ for mut pane in extracted_panes {
+ let run_instruction = pane.invoked_with().clone();
+ let pane_id = pane.pid();
+ let without_relayout = true;
+
+ // we reset the pane geom here to screen size so that we won't have trouble adding it
+ // temporarily to the new tab (eg. if it was stacked or had a fixed size), the size
+ // will be adjusted before the next render, further down the pipeline, when we apply
+ // the layout to this new tab
+ let new_geom = PaneGeom::from(&self.size);
+ pane.set_geom(new_geom);
+
+ // here we pass None instead of the ClientId, because we do not want this pane to be
+ // necessarily focused
+ tab.add_tiled_pane(pane, pane_id, without_relayout, None)?;
+ tiled_panes_layout.ignore_run_instruction(run_instruction.clone());
+ }
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+ self.bus.senders.send_to_plugin(PluginInstruction::NewTab(
+ None,
+ default_shell,
+ Some(tiled_panes_layout),
+ floating_panes_layout,
+ tab_index,
+ None, // initial_panes
+ false, // block_on_first_terminal
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ None,
+ ))?;
+ Ok(tab_index)
+ }
+ pub fn break_pane_to_new_tab(
+ &mut self,
+ direction: Direction,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let err_context = || "failed break pane out of tab".to_string();
+ if self.tabs.len() > 1 {
+ let (active_pane_id, active_pane, pane_to_break_is_floating) = {
+ let active_tab = self.get_active_tab_mut(client_id)?;
+ let active_pane_id = active_tab
+ .get_active_pane_id(client_id)
+ .with_context(err_context)?;
+ let pane_to_break_is_floating = active_tab.are_floating_panes_visible();
+ let active_pane = active_tab
+ .extract_pane(active_pane_id, false)
+ .with_context(err_context)?;
+ (active_pane_id, active_pane, pane_to_break_is_floating)
+ };
+ let update_mode_infos = false;
+ match direction {
+ Direction::Right | Direction::Down => {
+ self.switch_tab_next(None, update_mode_infos, client_id)?;
+ },
+ Direction::Left | Direction::Up => {
+ self.switch_tab_prev(None, update_mode_infos, client_id)?;
+ },
+ };
+ let new_active_tab = self.get_active_tab_mut(client_id)?;
+
+ if pane_to_break_is_floating {
+ new_active_tab.show_floating_panes();
+ new_active_tab.add_floating_pane(
+ active_pane,
+ active_pane_id,
+ None,
+ true,
+ Some(client_id),
+ )?;
+ } else {
+ new_active_tab.hide_floating_panes();
+ new_active_tab.add_tiled_pane(
+ active_pane,
+ active_pane_id,
+ false,
+ Some(client_id),
+ )?;
+ }
+
+ self.log_and_report_session_state()?;
+ } else {
+ let active_pane_id = {
+ let active_tab = self.get_active_tab_mut(client_id)?;
+ active_tab
+ .get_active_pane_id(client_id)
+ .with_context(err_context)?
+ };
+ self.bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::DisplayPaneError(
+ vec![active_pane_id],
+ "No other tabs to add pane to!".into(),
+ ))
+ .with_context(err_context)?;
+ }
+ self.render(None)?;
+ Ok(())
+ }
+ pub fn break_multiple_panes_to_tab_with_index(
+ &mut self,
+ pane_ids: Vec<PaneId>,
+ tab_index: usize,
+ should_change_focus_to_new_tab: bool,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let all_tabs = self.get_tabs_mut();
+ let has_tab_with_index = all_tabs
+ .values()
+ .find(|t| t.position == tab_index)
+ .is_some();
+ if !has_tab_with_index {
+ log::error!("Cannot find tab with index: {tab_index}");
+ return Ok(());
+ }
+ let mut extracted_panes = vec![];
+ for pane_id in pane_ids {
+ for tab in all_tabs.values_mut() {
+ if tab.position == tab_index {
+ continue;
+ }
+ // here we pass None instead of the client_id we have because we do not need to
+ // necessarily trigger a relayout for this tab
+ let pane_was_floating = tab.pane_id_is_floating(&pane_id);
+ if let Some(pane) = tab.extract_pane(pane_id, true).take() {
+ extracted_panes.push((pane_was_floating, pane));
+ break;
+ }
+ }
+ }
+
+ if should_change_focus_to_new_tab {
+ self.go_to_tab(tab_index + 1, client_id)?;
+ }
+ if extracted_panes.is_empty() {
+ // nothing to do here...
+ return Ok(());
+ }
+ let screen_size = self.size;
+ if let Some(new_active_tab) = self.get_indexed_tab_mut(tab_index) {
+ for (pane_was_floating, mut pane) in extracted_panes {
+ let pane_id = pane.pid();
+ if pane_was_floating {
+ let floating_pane_coordinates = FloatingPaneCoordinates {
+ x: Some(PercentOrFixed::Fixed(pane.x())),
+ y: Some(PercentOrFixed::Fixed(pane.y())),
+ width: Some(PercentOrFixed::Fixed(pane.cols())),
+ height: Some(PercentOrFixed::Fixed(pane.rows())),
+ pinned: Some(pane.current_geom().is_pinned),
+ borderless: Some(pane.borderless()),
+ };
+ new_active_tab.add_floating_pane(
+ pane,
+ pane_id,
+ Some(floating_pane_coordinates),
+ false,
+ Some(client_id),
+ )?;
+ } else {
+ // here we pass None instead of the ClientId, because we do not want this pane to be
+ // necessarily focused
+
+ // we reset the pane geom here to screen size so that we won't have trouble adding it
+ // temporarily to the new tab (eg. if it was stacked or had a fixed size), the size
+ // will be adjusted before the next render, further down the pipeline, when we apply
+ // the layout to this new tab
+ let new_geom = PaneGeom::from(&screen_size);
+ pane.set_geom(new_geom);
+
+ new_active_tab.add_tiled_pane(pane, pane_id, false, None)?;
+ }
+ }
+ } else {
+ log::error!("Could not find tab with index: {:?}", tab_index);
+ }
+ self.log_and_report_session_state()?;
+ Ok(())
+ }
+ pub fn replace_pane(
+ &mut self,
+ new_pane_id: PaneId,
+ hold_for_command: HoldForCommand,
+ run: Option<Run>,
+ pane_title: Option<InitialTitle>,
+ close_replaced_pane: bool,
+ client_id_tab_index_or_pane_id: ClientTabIndexOrPaneId,
+ ) -> Result<()> {
+ let suppress_pane = |tab: &mut Tab, pane_id: PaneId, new_pane_id: PaneId| {
+ let _ = tab.suppress_pane_and_replace_with_pid(
+ pane_id,
+ new_pane_id,
+ close_replaced_pane,
+ run,
+ None,
+ None,
+ );
+ if let Some(pane_title) = pane_title {
+ let _ = tab.rename_pane(pane_title.as_bytes().to_vec(), new_pane_id);
+ }
+ if let Some(hold_for_command) = hold_for_command {
+ let is_first_run = true;
+ tab.hold_pane(new_pane_id, None, is_first_run, hold_for_command)
+ }
+ };
+ match client_id_tab_index_or_pane_id {
+ ClientTabIndexOrPaneId::ClientId(client_id)
+ | ClientTabIndexOrPaneId::ClientIdNoFocus(client_id) => {
+ active_tab!(self, client_id, |tab: &mut Tab| {
+ match tab.get_active_pane_id(client_id) {
+ Some(pane_id) => {
+ suppress_pane(tab, pane_id, new_pane_id);
+ },
+ None => {
+ log::error!(
+ "Failed to find active pane for client id: {:?}",
+ client_id
+ );
+ },
+ }
+ });
+ },
+ ClientTabIndexOrPaneId::PaneId(pane_id) => {
+ let tab_index = self
+ .tabs
+ .iter()
+ .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id))
+ .map(|(_tab_index, tab)| tab.position);
+ match tab_index {
+ Some(tab_index) => {
+ if let Some(tab) =
+ self.tabs.iter_mut().find(|(_, t)| t.position == tab_index)
+ {
+ suppress_pane(tab.1, pane_id, new_pane_id);
+ }
+ },
+ None => {
+ log::error!("Could not find pane with id: {:?}", pane_id);
+ },
+ };
+ },
+ ClientTabIndexOrPaneId::TabIndex(_tab_index)
+ | ClientTabIndexOrPaneId::TabIndexNoFocus(_tab_index) => {
+ log::error!("Cannot replace pane with tab index");
+ },
+ }
+ Ok(())
+ }
+ pub fn replace_pane_with_existing_pane(
+ &mut self,
+ pane_id_to_replace: PaneId,
+ pane_id_of_existing_pane: PaneId,
+ suppress_replaced_pane: bool,
+ _completion_tx: Option<NotificationEnd>, // ends here
+ ) {
+ let Some(tab_index_of_pane_id_to_replace) = self
+ .tabs
+ .iter()
+ .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id_to_replace))
+ .map(|(_tab_index, tab)| tab.position)
+ else {
+ log::error!(
+ "Could not find tab with pane_id: {:?} to replace",
+ pane_id_to_replace
+ );
+ return;
+ };
+ let Some(tab_index_of_existing_pane) = self
+ .tabs
+ .iter()
+ .find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id_of_existing_pane))
+ .map(|(_tab_index, tab)| tab.position)
+ else {
+ log::error!(
+ "Could not find tab with pane_id: {:?} to be replaced by",
+ pane_id_of_existing_pane
+ );
+ return;
+ };
+ let Some(extracted_pane_from_other_tab) = self
+ .tabs
+ .iter_mut()
+ .find(|(_, t)| t.position == tab_index_of_existing_pane)
+ .and_then(|(_, t)| t.extract_pane(pane_id_of_existing_pane, true))
+ else {
+ log::error!("Failed to find pane");
+ return;
+ };
+ if let Some(tab) = self
+ .tabs
+ .iter_mut()
+ .find(|(_, t)| t.position == tab_index_of_pane_id_to_replace)
+ {
+ if suppress_replaced_pane {
+ tab.1.suppress_pane_and_replace_with_other_pane(
+ pane_id_to_replace,
+ extracted_pane_from_other_tab,
+ None,
+ );
+ } else {
+ tab.1.close_pane_and_replace_with_other_pane(
+ pane_id_to_replace,
+ extracted_pane_from_other_tab,
+ None,
+ );
+ }
+ }
+ let _ = self.log_and_report_session_state();
+ }
+ pub fn reconfigure(
+ &mut self,
+ new_keybinds: Keybinds,
+ new_default_mode: InputMode,
+ theme: Styling,
+ simplified_ui: bool,
+ default_shell: Option<PathBuf>,
+ pane_frame_style: PaneFrameStyle,
+ copy_command: Option<String>,
+ copy_to_clipboard: Option<Clipboard>,
+ copy_on_select: bool,
+ auto_layout: bool,
+ rounded_corners: bool,
+ hide_session_name: bool,
+ stacked_resize: bool,
+ stacked_pane_list: bool,
+ default_editor: Option<PathBuf>,
+ advanced_mouse_actions: bool,
+ mouse_scroll_resize: bool,
+ mouse_hover_effects: bool,
+ visual_bell: bool,
+ focus_follows_mouse: bool,
+ mouse_click_through: bool,
+ nested_session_handling: NestedSessionHandling,
+ client_id: ClientId,
+ ) -> Result<()> {
+ let should_support_arrow_fonts = !simplified_ui;
+ self.arrow_fonts = should_support_arrow_fonts;
+
+ // global configuration
+ self.default_mode_info.update_theme(theme);
+ self.default_mode_info
+ .update_rounded_corners(rounded_corners);
+ // `default_mode_info` is the fallback used by `change_mode` for
+ // clients that don't yet have a per-client `mode_info` entry, so its
+ // keybinds and base mode must be kept in sync with reconfigures.
+ self.default_mode_info.update_keybinds(new_keybinds.clone());
+ self.default_mode_info.update_default_mode(new_default_mode);
+ self.default_shell = default_shell.clone().unwrap_or_else(|| get_default_shell());
+ self.default_editor = default_editor.clone().or_else(|| get_default_editor());
+ self.auto_layout = auto_layout;
+ self.copy_options.command = copy_command.clone();
+ self.copy_options.copy_on_select = copy_on_select;
+ self.pane_frame_style = pane_frame_style;
+ self.advanced_mouse_actions = advanced_mouse_actions;
+ self.mouse_scroll_resize = mouse_scroll_resize;
+ self.mouse_hover_effects = mouse_hover_effects;
+ self.visual_bell = visual_bell;
+ self.focus_follows_mouse = focus_follows_mouse;
+ self.mouse_click_through = mouse_click_through;
+ self.nested_session_handling = nested_session_handling;
+ self.default_mode_info
+ .update_arrow_fonts(should_support_arrow_fonts);
+ self.default_mode_info
+ .update_hide_session_name(hide_session_name);
+ {
+ *self.stacked_resize.borrow_mut() = stacked_resize;
+ }
+ {
+ *self.stacked_pane_list.borrow_mut() = stacked_pane_list;
+ }
+ if let Some(copy_to_clipboard) = copy_to_clipboard {
+ self.copy_options.clipboard = copy_to_clipboard;
+ }
+ for tab in self.tabs.values_mut() {
+ tab.update_theme(theme);
+ tab.update_rounded_corners(rounded_corners);
+ tab.update_default_shell(default_shell.clone());
+ tab.update_default_editor(self.default_editor.clone());
+ tab.update_auto_layout(auto_layout);
+ tab.update_copy_options(&self.copy_options);
+ tab.set_pane_frames(pane_frame_style);
+ tab.update_arrow_fonts(should_support_arrow_fonts);
+ tab.update_advanced_mouse_actions(advanced_mouse_actions);
+ tab.update_mouse_scroll_resize(mouse_scroll_resize);
+ tab.update_mouse_hover_effects(mouse_hover_effects);
+ tab.update_focus_follows_mouse(focus_follows_mouse);
+ tab.update_mouse_click_through(mouse_click_through);
+ tab.sync_stacked_pane_list_mode();
+ }
+
+ // Clear hover state when disabled
+ if !mouse_hover_effects {
+ for tab in self.tabs.values_mut() {
+ tab.clear_mouse_hover_state();
+ }
+ }
+
+ // client specific configuration
+ if self.connected_clients_contains(&client_id) {
+ let mode_info = self
+ .mode_info
+ .entry(client_id)
+ .or_insert_with(|| self.default_mode_info.clone());
+ mode_info.update_keybinds(new_keybinds);
+ mode_info.update_default_mode(new_default_mode);
+ mode_info.update_theme(theme);
+ mode_info.update_arrow_fonts(should_support_arrow_fonts);
+ mode_info.update_hide_session_name(hide_session_name);
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info.clone(), client_id);
+ tab.mark_active_pane_for_rerender(client_id);
+ }
+ }
+
+ // this needs to be done separately at the end because it applies some of the above changes
+ // and propagates them to plugins
+ for tab in self.tabs.values_mut() {
+ tab.update_input_modes()?;
+ }
+ self.broadcast_nested_shortcuts();
+ Ok(())
+ }
+ /// Apply a host-reported color-palette theme mode (CSI 2031 / DSR 997).
+ ///
+ /// This is the Phase 2 entry-point: it
+ /// 1. de-duplicates against the last-known mode,
+ /// 2. swaps the active palette to the configured `theme_dark` / `theme_light`
+ /// (when both are configured) by reusing the reconfigure propagation,
+ /// 3. fans out an `Event::HostTerminalThemeChanged` plugin event,
+ /// 4. forwards a `CSI ?997;{1|2}n` DSR onto the pty of every terminal pane
+ /// whose app opted in via `CSI ? 2031 h`.
+ pub fn update_host_terminal_theme_mode(&mut self, mode: HostTerminalThemeMode) -> Result<()> {
+ let err_context = || "Failed to update host terminal theme mode".to_string();
+
+ // dedupe
+ if self.host_terminal_theme_mode == Some(mode) {
+ return Ok(());
+ }
+ self.host_terminal_theme_mode = Some(mode);
+
+ // resolve target styling
+ let resolved = match mode {
+ HostTerminalThemeMode::Dark => self.host_theme_dark_styling,
+ HostTerminalThemeMode::Light => self.host_theme_light_styling,
+ };
+
+ // theme propagation when both keys configured and the resolved
+ // styling exists. (If only one of theme_dark/theme_light is set,
+ // skip auto-switch; the static `theme` stays authoritative.)
+ let auto_switch_enabled =
+ self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some();
+ if auto_switch_enabled {
+ if let Some(theme) = resolved {
+ self.default_mode_info.update_theme(theme);
+ for tab in self.tabs.values_mut() {
+ tab.update_theme(theme);
+ }
+ // Iterate every connected client (active_tab_ids is the
+ // canonical "who is connected" map). Iterating
+ // self.mode_info.keys() would skip any client that has
+ // never manually changed mode
+ let client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect();
+ let default_for_new = self.default_mode_info.clone();
+ for client_id in client_ids {
+ let mode_info = self
+ .mode_info
+ .entry(client_id)
+ .or_insert_with(|| default_for_new.clone());
+ mode_info.update_theme(theme);
+ let mode_info_clone = mode_info.clone();
+ // Push the freshly-themed mode_info into every tab's
+ // per-client mode_info map. `update_input_modes` below
+ // reads from THAT map (not Screen's) when fanning out
+ // ModeUpdate to plugins, so without this step plugins
+ // receive ModeUpdate carrying the *old* style and the
+ // status-bar / tab-bar do not repaint. Also mark the
+ // active pane for rerender so terminal panes refresh
+ // their borders/title using the new palette.
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info_clone.clone(), client_id);
+ tab.mark_active_pane_for_rerender(client_id);
+ }
+ }
+ for tab in self.tabs.values_mut() {
+ tab.update_input_modes().with_context(err_context)?;
+ }
+ } else {
+ log::warn!(
+ "host theme auto-switch enabled but resolved styling missing for {:?}",
+ mode
+ );
+ }
+ }
+
+ // fan out the plugin event
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(vec![(
+ None,
+ None,
+ Event::HostTerminalThemeChanged(mode),
+ )]))
+ .with_context(err_context)?;
+
+ // forward DSR to opted-in terminal panes
+ let mut pty_writes: Vec<(Vec<u8>, u32)> = vec![];
+ for tab in self.tabs.values_mut() {
+ for pane_id in tab.get_all_pane_ids() {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
+ pane.push_color_palette_dsr(mode);
+ for bytes in pane.drain_messages_to_pty() {
+ pty_writes.push((bytes, terminal_id));
+ }
+ }
+ }
+ }
+ }
+ for (bytes, terminal_id) in pty_writes {
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(bytes, terminal_id, None));
+ }
+
+ self.render(None)?;
+ Ok(())
+ }
+ /// Apply a manual host-terminal theme mode change requested via the CLI or a
+ /// keybinding. Surfaces a clear error to the CLI if the auto-switch gate
+ /// (both `theme_dark` and `theme_light` configured) is not satisfied;
+ /// otherwise delegates to `update_host_terminal_theme_mode`, which dedupes,
+ /// repaints, fans out the plugin event, and forwards DSR to opted-in panes.
+ pub fn apply_manual_host_terminal_theme_mode(
+ &mut self,
+ mode: HostTerminalThemeMode,
+ completion_tx: &mut Option<NotificationEnd>,
+ ) -> Result<()> {
+ let auto_switch_enabled =
+ self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some();
+ if !auto_switch_enabled {
+ if let Some(c) = completion_tx.as_mut() {
+ c.set_exit_status(1);
+ c.set_error_message(
+ "Manual theme switching requires both `theme_dark` and `theme_light` to be configured."
+ .to_string(),
+ );
+ }
+ return Ok(());
+ }
+ self.update_host_terminal_theme_mode(mode)
+ }
+ pub fn toggle_pane_pinned(&mut self, client_id: ClientId) {
+ active_tab_and_connected_client_id!(
+ self,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| {
+ tab.toggle_pane_pinned(client_id);
+ }
+ );
+ }
+ pub fn set_floating_pane_pinned(&mut self, pane_id: PaneId, should_be_pinned: bool) {
+ let mut found = false;
+ for tab in self.tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_floating_pane_pinned(pane_id, should_be_pinned);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!(
+ "Failed to find pane with id: {:?} to set as pinned",
+ pane_id
+ );
+ }
+ }
+ pub fn stack_panes(&mut self, mut pane_ids_to_stack: Vec<PaneId>) -> Option<PaneId> {
+ // if successful, returns the pane id of the last pane in the stack
+ if pane_ids_to_stack.is_empty() {
+ log::error!("Got an empty list of pane_ids to stack");
+ return None;
+ }
+ let stack_size = pane_ids_to_stack.len();
+ let root_pane_id = pane_ids_to_stack.remove(0);
+ let last_pane_id = pane_ids_to_stack.last();
+ let Some(root_tab_id) = self
+ .tabs
+ .iter()
+ .find_map(|(tab_id, tab)| {
+ if tab.has_pane_with_pid(&root_pane_id) {
+ Some(tab_id)
+ } else {
+ None
+ }
+ })
+ .copied()
+ else {
+ log::error!("Failed to find tab for root_pane_id: {:?}", root_pane_id);
+ return None;
+ };
+ let root_pane_id_is_floating = self
+ .tabs
+ .get(&root_tab_id)
+ .map(|t| t.pane_id_is_floating(&root_pane_id))
+ .unwrap_or(false);
+
+ if root_pane_id_is_floating {
+ self.tabs.get_mut(&root_tab_id).map(|tab| {
+ let _ = tab.toggle_pane_embed_or_floating_for_pane_id(root_pane_id, None);
+ });
+ }
+
+ let mut panes_to_stack = vec![];
+ let target_tab_has_room_for_stack = self
+ .tabs
+ .get_mut(&root_tab_id)
+ .map(|t| t.has_room_for_stack(root_pane_id, stack_size))
+ .unwrap_or(false);
+ if !target_tab_has_room_for_stack {
+ log::error!("No room for stack with root pane id: {:?}", root_pane_id);
+ return None;
+ }
+
+ for (tab_id, tab) in self.tabs.iter_mut() {
+ if tab_id == &root_tab_id {
+ // we do this before we extract panes so that the extraction won't trigger a
+ // relayout according to the next swapped tiled pane
+ tab.set_tiled_panes_damaged();
+ }
+ for pane_id in &pane_ids_to_stack {
+ if tab.has_pane_with_pid(&pane_id) {
+ match tab.extract_pane(*pane_id, false) {
+ Some(pane) => {
+ panes_to_stack.push(pane);
+ },
+ None => {
+ log::error!("Failed to extract pane: {:?}", pane_id);
+ },
+ }
+ }
+ }
+ }
+ self.tabs
+ .get_mut(&root_tab_id)
+ .map(|t| t.stack_panes(root_pane_id, panes_to_stack));
+ return last_pane_id.copied();
+ }
+ pub fn change_floating_panes_coordinates(
+ &mut self,
+ pane_ids_and_coordinates: Vec<(PaneId, FloatingPaneCoordinates)>,
+ ) {
+ for (pane_id, coordinates) in pane_ids_and_coordinates {
+ for (_tab_id, tab) in self.tabs.iter_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.change_floating_pane_coordinates(&pane_id, coordinates)
+ .non_fatal();
+ break;
+ }
+ }
+ }
+ }
+ pub fn toggle_pane_borderless(&mut self, pane_id: PaneId) {
+ for (_tab_id, tab) in self.tabs.iter_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_borderless(&pane_id).non_fatal();
+ break;
+ }
+ }
+ }
+ pub fn set_pane_borderless(&mut self, pane_id: PaneId, borderless: bool) {
+ for (_tab_id, tab) in self.tabs.iter_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_pane_borderless(&pane_id, borderless).non_fatal();
+ break;
+ }
+ }
+ }
+ pub fn handle_mouse_event(&mut self, event: MouseEvent, client_id: ClientId) {
+ let is_bare_motion = event.event_type == MouseEventType::Motion
+ && !event.left
+ && !event.right
+ && !event.middle
+ && !event.wheel_up
+ && !event.wheel_down;
+ let active_pane_id_before = self
+ .get_active_tab(client_id)
+ .ok()
+ .and_then(|tab| tab.get_active_pane_id(client_id));
+ match self
+ .get_active_tab_mut(client_id)
+ .and_then(|tab| tab.handle_mouse_event(&event, client_id))
+ {
+ Ok(mouse_effect) => {
+ let mut should_render = false;
+ if let Some(pane_id) = mouse_effect.group_toggle {
+ if self.advanced_mouse_actions {
+ self.toggle_pane_id_in_group(pane_id, &client_id);
+ should_render = true;
+ }
+ }
+ if let Some(pane_id) = mouse_effect.group_add {
+ if self.advanced_mouse_actions {
+ self.add_pane_id_to_group(pane_id, &client_id);
+ should_render = true;
+ }
+ }
+ if mouse_effect.ungroup {
+ if self.advanced_mouse_actions {
+ self.clear_pane_group(&client_id);
+ should_render = true;
+ }
+ }
+ if mouse_effect.state_changed {
+ if !is_bare_motion {
+ let _ = self.log_and_report_session_state();
+ }
+ let active_pane_id_after = self
+ .get_active_tab(client_id)
+ .ok()
+ .and_then(|tab| tab.get_active_pane_id(client_id));
+ if active_pane_id_before.is_some()
+ && active_pane_id_before != active_pane_id_after
+ {
+ self.clear_bell_for_focused_pane(client_id);
+ if let (Some(old), Some(new)) =
+ (active_pane_id_before, active_pane_id_after)
+ {
+ self.report_key_passthrough_state(client_id, old, new);
+ }
+ }
+ should_render = true;
+ }
+ if !mouse_effect.leave_clipboard_message && !is_bare_motion {
+ let target_plugin_ids =
+ self.targeted_plugin_ids(client_id, EventType::InputReceived);
+ let plugin_updates: Vec<_> = target_plugin_ids
+ .into_iter()
+ .map(|pid| (Some(pid), Some(client_id), Event::InputReceived))
+ .collect();
+ if !plugin_updates.is_empty() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(plugin_updates));
+ }
+ should_render = true;
+ }
+ if should_render {
+ self.render(None).non_fatal();
+ }
+ },
+ Err(e) => {
+ log::error!("Failed to process MouseEvent: {}", e);
+ },
+ }
+ }
+ pub fn toggle_pane_in_group(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = "Can't add pane to group";
+ let active_tab = self
+ .get_active_tab(client_id)
+ .with_context(|| err_context)?;
+ let active_pane_id = active_tab
+ .get_active_pane_id(client_id)
+ .with_context(|| err_context)?;
+ self.toggle_pane_id_in_group(active_pane_id, &client_id);
+ let _ = self.log_and_report_session_state();
+ Ok(())
+ }
+ pub fn toggle_group_marking(&mut self, client_id: ClientId) -> Result<()> {
+ let (was_marking_before, marking_pane_group_now) = {
+ let mut currently_marking_pane_group = self.currently_marking_pane_group.borrow_mut();
+ let previous_value = currently_marking_pane_group
+ .remove(&client_id)
+ .unwrap_or(false);
+ let new_value = !previous_value;
+ if new_value {
+ currently_marking_pane_group.insert(client_id, true);
+ }
+ (previous_value, new_value)
+ };
+ if marking_pane_group_now {
+ let active_pane_id = self.get_active_pane_id(&client_id);
+ if let Some(active_pane_id) = active_pane_id {
+ self.add_pane_id_to_group(active_pane_id, &client_id);
+ }
+ }
+ let value_changed = was_marking_before != marking_pane_group_now;
+ if value_changed {
+ for tab in self.tabs.values_mut() {
+ tab.update_input_modes()?;
+ }
+ let _ = self.log_and_report_session_state();
+ }
+ Ok(())
+ }
+ fn get_layout_metadata(
+ &self,
+ default_shell: Option<PathBuf>,
+ tab_index: Option<usize>,
+ ) -> SessionLayoutMetadata {
+ let mut session_layout_metadata = SessionLayoutMetadata::new(self.default_layout.clone());
+ if let Some(default_shell) = default_shell {
+ session_layout_metadata.update_default_shell(default_shell);
+ }
+ let first_client_id = self.get_first_client_id();
+ let active_tab_index =
+ first_client_id.and_then(|client_id| self.active_tab_ids.get(&client_id));
+
+ // Filter tabs based on optional tab_index parameter
+ let tabs_to_process: Vec<_> = self
+ .tabs
+ .iter()
+ .filter(|(idx, _)| tab_index.map_or(true, |target| **idx == target))
+ .collect();
+
+ for (tab_index, tab) in tabs_to_process {
+ let tab_is_focused = active_tab_index == Some(&tab_index);
+ let hide_floating_panes = !tab.are_floating_panes_visible();
+ let mut suppressed_panes = HashMap::new();
+ for (triggering_pane_id, p) in tab.get_suppressed_panes() {
+ suppressed_panes.insert(*triggering_pane_id, p);
+ }
+
+ let all_connected_clients: Vec<ClientId> = self
+ .connected_clients
+ .borrow()
+ .iter()
+ .map(|(c, _i)| *c)
+ .filter(|c| self.active_tab_ids.get(&c) == Some(&tab_index))
+ .collect();
+
+ let mut active_pane_ids: HashMap<ClientId, Option<PaneId>> = HashMap::new();
+ for connected_client_id in &all_connected_clients {
+ active_pane_ids.insert(
+ *connected_client_id,
+ tab.get_active_pane_id(*connected_client_id),
+ );
+ }
+
+ let stack_list_geoms = tab.stack_list_serialization_geoms();
+ let tiled_panes: Vec<PaneLayoutMetadata> = tab
+ .get_tiled_panes()
+ .map(|(pane_id, p)| {
+ // here we look to see if this pane triggers any suppressed pane,
+ // and if so we take that suppressed pane - we do this because this
+ // is currently only the case the scrollback editing panes, and
+ // when dumping the layout we want the "real" pane and not the
+ // editor pane
+ let geom_override = stack_list_geoms.get(pane_id).copied();
+ let (pane_id, p) = match suppressed_panes.remove(pane_id) {
+ Some((is_scrollback_editor, suppressed_pane)) if *is_scrollback_editor => {
+ (suppressed_pane.pid(), suppressed_pane)
+ },
+ _ => (*pane_id, p),
+ };
+ (
+ pane_id,
+ p,
+ geom_override.unwrap_or_else(|| p.position_and_size()),
+ )
+ })
+ .chain(tab.hidden_stack_list_members_for_serialization())
+ .map(|(pane_id, p, geom)| {
+ let focused_clients: Vec<ClientId> = active_pane_ids
+ .iter()
+ .filter_map(|(c_id, p_id)| {
+ p_id.and_then(|p_id| if p_id == pane_id { Some(*c_id) } else { None })
+ })
+ .collect();
+ let (default_fg, default_bg) = p.get_pane_default_colors();
+ PaneLayoutMetadata::new(
+ pane_id,
+ geom,
+ p.borderless(),
+ p.invoked_with().clone(),
+ p.custom_title(),
+ !focused_clients.is_empty(),
+ if self.serialize_pane_viewport {
+ p.serialize(self.scrollback_lines_to_serialize)
+ } else {
+ None
+ },
+ focused_clients,
+ default_fg,
+ default_bg,
+ )
+ })
+ .collect();
+ let floating_panes: Vec<PaneLayoutMetadata> = tab
+ .get_floating_panes()
+ .map(|(pane_id, p)| {
+ // here we look to see if this pane triggers any suppressed pane,
+ // and if so we take that suppressed pane - we do this because this
+ // is currently only the case the scrollback editing panes, and
+ // when dumping the layout we want the "real" pane and not the
+ // editor pane
+ match suppressed_panes.remove(pane_id) {
+ Some((is_scrollback_editor, suppressed_pane)) if *is_scrollback_editor => {
+ (suppressed_pane.pid(), suppressed_pane)
+ },
+ _ => (*pane_id, p),
+ }
+ })
+ .map(|(pane_id, p)| {
+ let focused_clients: Vec<ClientId> = active_pane_ids
+ .iter()
+ .filter_map(|(c_id, p_id)| {
+ p_id.and_then(|p_id| if p_id == pane_id { Some(*c_id) } else { None })
+ })
+ .collect();
+ let (default_fg, default_bg) = p.get_pane_default_colors();
+ PaneLayoutMetadata::new(
+ pane_id,
+ p.position_and_size(),
+ false, // floating panes are never borderless
+ p.invoked_with().clone(),
+ p.custom_title(),
+ !focused_clients.is_empty(),
+ if self.serialize_pane_viewport {
+ p.serialize(self.scrollback_lines_to_serialize)
+ } else {
+ None
+ },
+ focused_clients,
+ default_fg,
+ default_bg,
+ )
+ })
+ .collect();
+ session_layout_metadata.add_tab(
+ tab.name.clone(),
+ tab_is_focused,
+ hide_floating_panes,
+ tiled_panes,
+ floating_panes,
+ );
+ }
+ session_layout_metadata
+ }
+ fn update_plugin_loading_stage(
+ &mut self,
+ pid: u32,
+ loading_indication: LoadingIndication,
+ ) -> bool {
+ let all_tabs = self.get_tabs_mut();
+ let mut found_plugin = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_plugin(pid) {
+ found_plugin = true;
+ tab.update_plugin_loading_stage(pid, loading_indication);
+ break;
+ }
+ }
+ found_plugin
+ }
+ fn connected_clients_contains(&self, client_id: &ClientId) -> bool {
+ self.connected_clients.borrow().contains_key(client_id)
+ }
+ fn get_client_pane_group(&self, client_id: &ClientId) -> HashSet<PaneId> {
+ self.current_pane_group
+ .borrow()
+ .get_client_pane_group(client_id)
+ }
+ fn clear_pane_group(&mut self, client_id: &ClientId) {
+ self.current_pane_group
+ .borrow_mut()
+ .clear_pane_group(client_id);
+ self.currently_marking_pane_group
+ .borrow_mut()
+ .remove(client_id);
+ }
+ fn toggle_pane_id_in_group(&mut self, pane_id: PaneId, client_id: &ClientId) {
+ {
+ let mut pane_groups = self.current_pane_group.borrow_mut();
+ pane_groups.toggle_pane_id_in_group(pane_id, self.size, client_id);
+ }
+ self.retain_only_existing_panes_in_pane_groups();
+ }
+ fn add_pane_id_to_group(&mut self, pane_id: PaneId, client_id: &ClientId) {
+ {
+ let mut pane_groups = self.current_pane_group.borrow_mut();
+ pane_groups.add_pane_id_to_group(pane_id, self.size, client_id);
+ }
+ self.retain_only_existing_panes_in_pane_groups();
+ }
+ fn add_active_pane_to_group_if_marking(&mut self, client_id: &ClientId) {
+ {
+ if self
+ .currently_marking_pane_group
+ .borrow()
+ .get(client_id)
+ .copied()
+ .unwrap_or(false)
+ {
+ let active_pane_id = self.get_active_pane_id(&client_id);
+ if let Some(active_pane_id) = active_pane_id {
+ self.add_pane_id_to_group(active_pane_id, &client_id);
+ }
+ }
+ }
+ self.retain_only_existing_panes_in_pane_groups();
+ }
+ fn get_active_pane_id(&self, client_id: &ClientId) -> Option<PaneId> {
+ let active_tab = self.get_active_tab(*client_id).ok()?;
+ active_tab.get_active_pane_id(*client_id)
+ }
+
+ fn get_pane_info(&self, pane_id: PaneId) -> Option<PaneInfo> {
+ // Search through all tabs to find the pane
+ for tab in self.tabs.values() {
+ if let Some(pane_info) = tab.get_pane_info(pane_id) {
+ return Some(pane_info);
+ }
+ }
+ None
+ }
+
+ fn get_tab_info(&self, tab_id: usize) -> Option<TabInfo> {
+ // Look up tab by its stable ID
+ self.tabs.get(&tab_id).map(|tab| {
+ let mut all_focused_clients: Vec<ClientId> = self
+ .active_tab_ids
+ .iter()
+ .filter(|(_c_id, active_tab_id)| **active_tab_id == tab.id)
+ .map(|(c_id, _)| c_id)
+ .copied()
+ .collect();
+ for s_id in tab.shadow_focus_clients() {
+ if !all_focused_clients.contains(&s_id) {
+ all_focused_clients.push(s_id);
+ }
+ }
+ let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
+ let tab_viewport = tab.get_viewport();
+ let tab_display_area = tab.get_display_area();
+ let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count();
+ let selectable_floating_panes_count = tab.get_selectable_floating_panes_count();
+
+ TabInfo {
+ position: tab.position,
+ name: tab.name.clone(),
+ active: self.active_tab_ids.values().any(|i| i == &tab.id),
+ panes_to_hide: tab.panes_to_hide_count(),
+ is_fullscreen_active: tab.is_fullscreen_active(),
+ is_sync_panes_active: tab.is_sync_panes_active(),
+ are_floating_panes_visible: tab.are_floating_panes_visible(),
+ other_focused_clients: all_focused_clients,
+ active_swap_layout_name,
+ is_swap_layout_dirty,
+ viewport_rows: tab_viewport.rows,
+ viewport_columns: tab_viewport.cols,
+ display_area_rows: tab_display_area.rows,
+ display_area_columns: tab_display_area.cols,
+ selectable_tiled_panes_count,
+ selectable_floating_panes_count,
+ tab_id: tab.id,
+ has_bell_notification: tab.tab_has_pending_bell
+ && !self.active_tab_ids.values().any(|i| i == &tab.id),
+ is_flashing_bell: tab.tab_bell_flash
+ && !self.active_tab_ids.values().any(|i| i == &tab.id),
+ }
+ })
+ }
+
+ fn group_and_ungroup_panes(
+ &mut self,
+ pane_ids_to_group: Vec<PaneId>,
+ pane_ids_to_ungroup: Vec<PaneId>,
+ for_all_clients: bool,
+ client_id: ClientId,
+ ) {
+ if for_all_clients {
+ {
+ let mut current_pane_group = self.current_pane_group.borrow_mut();
+ current_pane_group.group_and_ungroup_panes_for_all_clients(
+ pane_ids_to_group,
+ pane_ids_to_ungroup,
+ self.size,
+ );
+ }
+ } else {
+ {
+ let mut current_pane_group = self.current_pane_group.borrow_mut();
+ current_pane_group.group_and_ungroup_panes(
+ pane_ids_to_group,
+ pane_ids_to_ungroup,
+ self.size,
+ &client_id,
+ );
+ }
+ }
+ self.retain_only_existing_panes_in_pane_groups();
+ let _ = self.log_and_report_session_state();
+ }
+ fn retain_only_existing_panes_in_pane_groups(&mut self) {
+ let clients_with_empty_group = {
+ let mut clients_with_empty_group = vec![];
+ let mut current_pane_group = { self.current_pane_group.borrow().clone_inner() };
+ for (client_id, panes_in_group) in current_pane_group.iter_mut() {
+ let all_tabs = self.get_tabs();
+ panes_in_group.retain(|p_id| {
+ let mut found = false;
+ for tab in all_tabs.values() {
+ if tab.has_pane_with_pid(&p_id) {
+ found = true;
+ break;
+ }
+ }
+ found
+ });
+ if panes_in_group.is_empty() {
+ clients_with_empty_group.push(*client_id)
+ }
+ }
+ self.current_pane_group
+ .borrow_mut()
+ .override_groups_with(current_pane_group);
+ clients_with_empty_group
+ };
+ for client_id in &clients_with_empty_group {
+ self.currently_marking_pane_group
+ .borrow_mut()
+ .remove(client_id);
+ }
+ if !clients_with_empty_group.is_empty() {
+ let all_tabs = self.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ let _ = tab.update_input_modes();
+ }
+ }
+ }
+ fn update_active_pane_ids(&mut self) {
+ let connected_clients: Vec<ClientId> =
+ self.connected_clients.borrow().keys().copied().collect();
+ for client_id in connected_clients {
+ if let Some(active_pane_id) = self.get_active_pane_id(&client_id) {
+ let active_pane_id: PaneId = active_pane_id.into();
+ let history = self.pane_history.entry(client_id).or_insert_with(|| vec![]);
+ history.retain(|e| e != &active_pane_id);
+ history.push(active_pane_id.into());
+ }
+ }
+ }
+ fn subscribe_to_pane_renders(
+ &mut self,
+ subscriber_client_id: ClientId,
+ pane_ids: Vec<zellij_utils::data::PaneId>,
+ scrollback: Option<usize>,
+ ansi: bool,
+ ) {
+ 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 = if ansi {
+ pane.pane_contents_with_ansi(
+ query_client_id,
+ get_full_scrollback,
+ max_lines,
+ )
+ } else {
+ 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,
+ ansi,
+ },
+ );
+ }
+ }
+ fn deliver_to_pane_subscribers_from_report(&mut self, report: &PaneRenderReport) {
+ let Some(pane_map) = report.all_pane_contents.values().next() else {
+ return;
+ };
+ let ansi_pane_map = report.all_pane_contents_with_ansi.values().next();
+ self.deliver_subscriber_updates_from_map(pane_map, ansi_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();
+
+ let has_ansi_subscribers = self.pane_render_subscribers.values().any(|s| s.ansi);
+
+ // Query pane contents directly from tabs
+ let mut pane_map: HashMap<zellij_utils::data::PaneId, PaneContents> = HashMap::new();
+ let mut ansi_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));
+ if has_ansi_subscribers {
+ ansi_pane_map
+ .insert(*pane_id, pane.pane_contents_with_ansi(None, false, None));
+ }
+ break;
+ }
+ }
+ }
+
+ let ansi_map_ref = if has_ansi_subscribers {
+ Some(&ansi_pane_map)
+ } else {
+ None
+ };
+ self.deliver_subscriber_updates_from_map(&pane_map, ansi_map_ref);
+ }
+ fn deliver_subscriber_updates_from_map(
+ &mut self,
+ pane_map: &HashMap<zellij_utils::data::PaneId, PaneContents>,
+ ansi_pane_map: Option<&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 {
+ let effective_map = if subscription.ansi {
+ ansi_pane_map.unwrap_or(pane_map)
+ } else {
+ pane_map
+ };
+
+ for pane_id in &subscription.pane_ids {
+ if let Some(contents) = effective_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))]
+fn get_default_editor() -> Option<PathBuf> {
+ std::env::var("EDITOR")
+ .or_else(|_| std::env::var("VISUAL"))
+ .map(|e| PathBuf::from(e))
+ .ok()
+}
+
+#[cfg(test)]
+fn get_default_editor() -> Option<PathBuf> {
+ None
+}
+
+fn find_already_running_panes(
+ tiled_layout: &TiledPaneLayout,
+ floating_layouts: &[FloatingPaneLayout],
+ active_tab: &Tab,
+) -> (Vec<Option<Run>>, Vec<usize>) {
+ let mut layout_tiled_instructions = tiled_layout.extract_run_instructions();
+ let running_tiled_instructions: Vec<Option<Run>> = active_tab
+ .get_tiled_panes()
+ .map(|(_, pane)| pane.invoked_with().clone())
+ .chain(
+ active_tab
+ .suppressed_stack_list_members()
+ .map(|(_, pane)| pane.invoked_with().clone()),
+ )
+ .collect();
+
+ let mut tiled_to_ignore = Vec::new();
+ for running_instr in running_tiled_instructions {
+ if let Some(pos) = layout_tiled_instructions
+ .iter()
+ .position(|layout_instr| layout_instr == &running_instr)
+ {
+ layout_tiled_instructions.remove(pos);
+ tiled_to_ignore.push(running_instr);
+ }
+ }
+
+ let mut running_floating_instructions: Vec<Option<Run>> = active_tab
+ .get_floating_panes()
+ .map(|(_, pane)| pane.invoked_with().clone())
+ .collect();
+
+ let mut floating_indices = Vec::new();
+ for (idx, floating_layout) in floating_layouts.iter().enumerate() {
+ if let Some(pos) = running_floating_instructions
+ .iter()
+ .position(|instr| instr == &floating_layout.run)
+ {
+ running_floating_instructions.remove(pos);
+ floating_indices.push(idx);
+ }
+ }
+
+ (tiled_to_ignore, floating_indices)
+}
+
+// The box is here in order to make the
+// NewClient enum smaller
+#[allow(clippy::boxed_local)]
+pub(crate) fn screen_thread_main(
+ bus: Bus<ScreenInstruction>,
+ max_panes: Option<usize>,
+ client_attributes: ClientAttributes,
+ config: Config,
+ debug: bool,
+ default_layout: Box<Layout>,
+) -> Result<()> {
+ // Resolve `theme_dark` / `theme_light` to concrete `Styling` from the
+ // bundled themes BEFORE `config.options` is moved out below. These
+ // populate Screen's auto-switch state at startup; runtime updates
+ // continue to flow through `propagate_configuration_changes`.
+ let host_theme_dark_styling = config
+ .options
+ .theme_dark
+ .as_ref()
+ .and_then(|name| config.themes.get_theme(name).map(|t| t.palette));
+ let host_theme_light_styling = config
+ .options
+ .theme_light
+ .as_ref()
+ .and_then(|name| config.themes.get_theme(name).map(|t| t.palette));
+ if config.options.theme_dark.is_some() && host_theme_dark_styling.is_none() {
+ log::warn!(
+ "theme_dark='{}' not found in themes; auto-theme switch disabled for dark.",
+ config.options.theme_dark.as_deref().unwrap_or("?")
+ );
+ }
+ if config.options.theme_light.is_some() && host_theme_light_styling.is_none() {
+ log::warn!(
+ "theme_light='{}' not found in themes; auto-theme switch disabled for light.",
+ config.options.theme_light.as_deref().unwrap_or("?")
+ );
+ }
+
+ let config_options = config.options;
+ let arrow_fonts = !config_options.simplified_ui.unwrap_or_default();
+ let pane_frame_style = PaneFrameStyle::from_options(&config_options);
+ let auto_layout = config_options.auto_layout.unwrap_or(true);
+ let session_serialization = config_options.session_serialization.unwrap_or(true);
+ let serialize_pane_viewport = config_options.serialize_pane_viewport.unwrap_or(false);
+ let scrollback_lines_to_serialize = config_options.scrollback_lines_to_serialize;
+ let session_is_mirrored = config_options.mirror_session.unwrap_or(false);
+ let layout_dir = config_options.layout_dir;
+ #[cfg(test)]
+ let default_shell = config_options
+ .default_shell
+ .clone()
+ .unwrap_or(PathBuf::from("/bin/sh"));
+ #[cfg(not(test))]
+ let default_shell = config_options
+ .default_shell
+ .clone()
+ .unwrap_or_else(|| get_default_shell());
+ let default_editor = config_options
+ .scrollback_editor
+ .clone()
+ .or_else(|| get_default_editor());
+ let default_layout_name = config_options
+ .default_layout
+ .map(|l| format!("{}", l.display()));
+ let copy_options = CopyOptions::new(
+ config_options.copy_command,
+ config_options.copy_clipboard.unwrap_or_default(),
+ config_options.copy_on_select.unwrap_or(true),
+ );
+ let web_server_ip = config_options
+ .web_server_ip
+ .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
+ let web_server_port = config_options.web_server_port.unwrap_or(8082);
+ let styled_underlines = config_options.styled_underlines.unwrap_or(true);
+ let osc8_hyperlinks = config_options.osc8_hyperlinks.unwrap_or(true);
+ let explicitly_disable_kitty_keyboard_protocol = config_options
+ .support_kitty_keyboard_protocol
+ .map(|e| !e) // this is due to the config options wording, if
+ // "support_kitty_keyboard_protocol" is true,
+ // explicitly_disable_kitty_keyboard_protocol is false and vice versa
+ .unwrap_or(false); // by default, we try to support this if the terminal supports it and
+ // the program running inside a pane requests it
+ let support_kitty_graphics_protocol = config_options
+ .support_kitty_graphics_protocol
+ .unwrap_or(true);
+ let stacked_resize = config_options.stacked_resize.unwrap_or(true);
+ let stacked_pane_list = config_options.stacked_pane_list.unwrap_or(true);
+ let web_clients_allowed = config_options
+ .web_sharing
+ .map(|s| s.web_clients_allowed())
+ .unwrap_or(false);
+ let web_sharing = config_options.web_sharing.unwrap_or_else(Default::default);
+ let advanced_mouse_actions = config_options.advanced_mouse_actions.unwrap_or(true);
+ let mouse_scroll_resize = config_options.mouse_scroll_resize.unwrap_or(true);
+ let mouse_hover_effects = config_options.mouse_hover_effects.unwrap_or(true);
+ let visual_bell = config_options.visual_bell.unwrap_or(true);
+ let focus_follows_mouse = config_options.focus_follows_mouse.unwrap_or(false);
+ let mouse_click_through = config_options.mouse_click_through.unwrap_or(false);
+ let nested_session_handling = config_options.nested_session_handling.unwrap_or_default();
+
+ let thread_senders = bus.senders.clone();
+ let mut screen = Screen::new(
+ bus,
+ &client_attributes,
+ max_panes,
+ get_mode_info(
+ config_options.default_mode.unwrap_or_default(),
+ &client_attributes,
+ PluginCapabilities {
+ // ¯\_(ツ)_/¯
+ arrow_fonts: !arrow_fonts,
+ },
+ &config.keybinds,
+ config_options.default_mode,
+ ),
+ pane_frame_style,
+ 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,
+ support_kitty_graphics_protocol,
+ stacked_resize,
+ stacked_pane_list,
+ default_editor,
+ web_clients_allowed,
+ web_sharing,
+ advanced_mouse_actions,
+ mouse_scroll_resize,
+ mouse_hover_effects,
+ visual_bell,
+ focus_follows_mouse,
+ mouse_click_through,
+ web_server_ip,
+ web_server_port,
+ nested_session_handling,
+ );
+ screen.host_theme_dark_styling = host_theme_dark_styling;
+ screen.host_theme_light_styling = host_theme_light_styling;
+
+ let mut pending_tab_ids: HashSet<usize> = HashSet::new();
+ let mut pending_tab_switches: HashSet<(usize, ClientId)> = HashSet::new(); // usize is the
+ // tab_index
+ let mut pending_events_waiting_for_tab: Vec<ScreenInstruction> = vec![];
+ let mut pending_events_waiting_for_client: Vec<ScreenInstruction> = vec![];
+ let mut pending_events_waiting_for_pane: HashMap<PaneId, Vec<ScreenInstruction>> =
+ HashMap::new();
+ let mut plugin_loading_message_cache = HashMap::new();
+ let mut keybind_intercepts = HashMap::new();
+ loop {
+ let (event, mut err_ctx) = screen
+ .bus
+ .recv()
+ .context("failed to receive event on channel")?;
+ err_ctx.add_call(ContextType::Screen((&event).into()));
+ // here we start caching resizes, so that we'll send them in bulk at the end of each event
+ // when this cache is Dropped, for more information, see the comments in PtyWriter
+ let _resize_cache = ResizeCache::new(thread_senders.clone());
+
+ match event {
+ ScreenInstruction::PtyBytes(pid, vte_bytes) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut vte_bytes = Some(vte_bytes);
+ for tab in all_tabs.values_mut() {
+ if tab.has_terminal_pid(pid) {
+ if let Some(bytes) = vte_bytes.take() {
+ tab.handle_pty_bytes(pid, bytes)
+ .context("failed to process pty bytes")?;
+ }
+ break;
+ }
+ }
+ if let Some(vte_bytes) = vte_bytes {
+ pending_events_waiting_for_pane
+ .entry(PaneId::Terminal(pid))
+ .or_default()
+ .push(ScreenInstruction::PtyBytes(pid, vte_bytes));
+ }
+ let _ = screen
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::RenderToClients);
+ },
+ ScreenInstruction::PluginBytes(mut plugin_render_assets) => {
+ for plugin_render_asset in plugin_render_assets.iter_mut() {
+ let plugin_id = plugin_render_asset.plugin_id;
+ let client_id = plugin_render_asset.client_id;
+ let vte_bytes: Vec<u8> = plugin_render_asset.bytes.drain(..).collect();
+ let painted_content = !vte_bytes.is_empty();
+
+ let mobile_tab_id = screen.mobile_state.mobile_tab_id(client_id);
+ let mut painted_tab_id = None;
+ let mut painted_size = None;
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_plugin(plugin_id) {
+ tab.handle_plugin_bytes(plugin_id, client_id, vte_bytes)
+ .context("failed to process plugin bytes")?;
+ painted_tab_id = Some(tab.id);
+ painted_size =
+ tab.get_pane_with_id(PaneId::Plugin(plugin_id))
+ .map(|pane| Size {
+ rows: pane.get_content_rows(),
+ cols: pane.get_content_columns(),
+ });
+ break;
+ }
+ }
+ if painted_content && painted_tab_id == mobile_tab_id && mobile_tab_id.is_some()
+ {
+ if let Some(painted_size) = painted_size {
+ screen
+ .mobile_render_gate
+ .record_paint_size(client_id, painted_size);
+ }
+ screen.try_lift_mobile_gate(client_id);
+ }
+ screen.render_blocker.remove_blocking_plugin(plugin_id);
+ }
+ screen.render(Some(plugin_render_assets))?;
+ },
+ ScreenInstruction::Render => {
+ screen.render(None)?;
+ },
+ ScreenInstruction::RenderToClients => {
+ // render_blocker.can_render() returning true means that either all pending plugins
+ // (only those waiting for a new tab layout to be applied!) have been rendered or
+ // that a 100ms timeout has been reached (more info in the RenderBlocker comment)
+ if screen.render_blocker.can_render() {
+ screen.render_to_clients()?;
+ } else {
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::NewPane(
+ pid,
+ initial_pane_title,
+ hold_for_command,
+ invoked_with,
+ new_pane_placement,
+ start_suppressed,
+ client_or_tab_index,
+ mut completion_tx,
+ set_blocking,
+ ) => {
+ completion_tx.as_mut().map(|c| c.set_affected_pane_id(pid));
+
+ let blocking_notification = if set_blocking { completion_tx } else { None };
+
+ match client_or_tab_index {
+ ClientTabIndexOrPaneId::ClientId(client_id)
+ | ClientTabIndexOrPaneId::ClientIdNoFocus(client_id) => {
+ let should_focus_pane =
+ matches!(client_or_tab_index, ClientTabIndexOrPaneId::ClientId(_));
+ active_tab_and_connected_client_id_with_first_tab_fallback!(screen, client_id, |tab: &mut Tab, client_id: Option<ClientId>| {
+ tab.new_pane(pid,
+ initial_pane_title,
+ invoked_with,
+ start_suppressed,
+ should_focus_pane,
+ new_pane_placement,
+ client_id,
+ blocking_notification
+ )
+ }, ?);
+ if let Some(hold_for_command) = hold_for_command {
+ let is_first_run = true;
+ active_tab_and_connected_client_id_with_first_tab_fallback!(
+ screen,
+ client_id,
+ |tab: &mut Tab, _client_id: Option<ClientId>| tab.hold_pane(
+ pid,
+ None,
+ is_first_run,
+ hold_for_command
+ )
+ )
+ }
+ },
+ ClientTabIndexOrPaneId::TabIndex(tab_index)
+ | ClientTabIndexOrPaneId::TabIndexNoFocus(tab_index) => {
+ let should_focus_pane =
+ matches!(client_or_tab_index, ClientTabIndexOrPaneId::TabIndex(_));
+ // Some placements (directional split, stacked without a
+ // target pane) need a client_id to know which pane to
+ // split relative to. Only resolve one when required.
+ let needs_client_id = matches!(
+ new_pane_placement,
+ NewPanePlacement::Tiled {
+ direction: Some(_),
+ ..
+ } | NewPanePlacement::Stacked {
+ pane_id_to_stack_under: None,
+ ..
+ }
+ );
+ let client_id = if needs_client_id {
+ screen
+ .active_tab_ids
+ .iter()
+ .find(|(_, tid)| **tid == tab_index)
+ .map(|(cid, _)| *cid)
+ .or_else(|| screen.active_tab_ids.keys().next().copied())
+ } else {
+ None
+ };
+ if let Some(active_tab) = screen.tabs.get_mut(&tab_index) {
+ active_tab.new_pane(
+ pid,
+ initial_pane_title,
+ invoked_with,
+ start_suppressed,
+ should_focus_pane,
+ new_pane_placement,
+ client_id,
+ blocking_notification,
+ )?;
+ if let Some(hold_for_command) = hold_for_command {
+ let is_first_run = true;
+ active_tab.hold_pane(pid, None, is_first_run, hold_for_command);
+ }
+ } else {
+ log::error!("Tab index not found: {:?}", tab_index);
+ }
+ },
+ ClientTabIndexOrPaneId::PaneId(pane_id) => {
+ let mut found = false;
+ let all_tabs = screen.get_tabs_mut();
+ let should_focus_pane = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ match new_pane_placement {
+ NewPanePlacement::Tiled {
+ direction: Some(direction),
+ borderless,
+ } => {
+ if direction == Direction::Left
+ || direction == Direction::Right
+ {
+ tab.vertical_split_of_pane_id(
+ pid,
+ initial_pane_title,
+ invoked_with,
+ pane_id,
+ blocking_notification,
+ borderless,
+ )?;
+ } else {
+ tab.horizontal_split_of_pane_id(
+ pid,
+ initial_pane_title,
+ invoked_with,
+ pane_id,
+ blocking_notification,
+ borderless,
+ )?;
+ }
+ },
+ _ => {
+ tab.new_pane(
+ pid,
+ initial_pane_title,
+ invoked_with,
+ start_suppressed,
+ should_focus_pane,
+ new_pane_placement,
+ None,
+ blocking_notification,
+ )?;
+ },
+ }
+ if let Some(hold_for_command) = hold_for_command {
+ let is_first_run = true;
+ tab.hold_pane(pid, None, is_first_run, hold_for_command);
+ }
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!(
+ "Failed to find tab containing pane with id: {:?}",
+ pane_id
+ );
+ }
+ },
+ };
+ if let Some(pending_events) = pending_events_waiting_for_pane.remove(&pid) {
+ for event in pending_events {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+ }
+ screen.log_and_report_session_state()?;
+
+ screen.render(None)?;
+ },
+ ScreenInstruction::OpenInPlaceEditor(pid, client_tab_index_or_pane_id) => {
+ match client_tab_index_or_pane_id {
+ ClientTabIndexOrPaneId::ClientId(client_id)
+ | ClientTabIndexOrPaneId::ClientIdNoFocus(client_id) => {
+ active_tab!(screen, client_id, |tab: &mut Tab| tab
+ .replace_active_pane_with_editor_pane(pid, client_id), ?);
+ screen.log_and_report_session_state()?;
+ },
+ ClientTabIndexOrPaneId::TabIndex(_tab_index)
+ | ClientTabIndexOrPaneId::TabIndexNoFocus(_tab_index) => {
+ log::error!("Cannot OpenInPlaceEditor with a TabIndex");
+ },
+ ClientTabIndexOrPaneId::PaneId(pane_id_to_replace) => {
+ let mut found = false;
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id_to_replace) {
+ tab.replace_pane_with_editor_pane(pid, pane_id_to_replace)
+ .non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!(
+ "Could not find pane with id {:?} to replace",
+ pane_id_to_replace
+ );
+ }
+ },
+ }
+
+ if let Some(pending_events) = pending_events_waiting_for_pane.remove(&pid) {
+ for event in pending_events {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+ }
+
+ screen.render(None)?;
+ },
+ ScreenInstruction::TogglePaneEmbedOrFloating(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(screen, client_id, |tab: &mut Tab, client_id: ClientId| tab
+ .toggle_pane_embed_or_floating(client_id), ?);
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleFloatingPanes(client_id, default_shell, completion_tx) => {
+ active_tab_and_connected_client_id!(screen, client_id, |tab: &mut Tab, client_id: ClientId| tab
+ .toggle_floating_panes(Some(client_id), default_shell, completion_tx), ?);
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.log_and_report_session_state()?;
+
+ screen.render(None)?;
+ },
+ ScreenInstruction::ShowFloatingPanes {
+ client_id,
+ tab_id,
+ completion,
+ } => {
+ screen.show_floating_panes_in_tab(client_id, tab_id, completion)?;
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::HideFloatingPanes {
+ client_id,
+ tab_id,
+ completion,
+ } => {
+ screen.hide_floating_panes_in_tab(client_id, tab_id, completion)?;
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::AreFloatingPanesVisible {
+ client_id,
+ tab_id,
+ completion,
+ } => {
+ screen.are_floating_panes_visible_in_tab(client_id, tab_id, completion)?;
+ },
+ ScreenInstruction::WriteCharacter(
+ key_with_modifier,
+ raw_bytes,
+ is_kitty_keyboard_protocol,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ if let Some(plugin_id) = keybind_intercepts.get(&client_id) {
+ if let Some(key_with_modifier) = key_with_modifier {
+ let _ = screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(vec![(
+ Some(*plugin_id),
+ Some(client_id),
+ Event::InterceptedKeyPress(key_with_modifier),
+ )]));
+ continue;
+ }
+ }
+ let mut state_changed = false;
+ let client_input_mode = screen.get_client_input_mode(client_id);
+ match client_input_mode {
+ Some(InputMode::RenameTab) => {
+ if !(raw_bytes == BRACKETED_PASTE_BEGIN || raw_bytes == BRACKETED_PASTE_END)
+ {
+ screen.update_active_tab_name(raw_bytes, client_id)?;
+ state_changed = true;
+ }
+ },
+ _ => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| {
+ match client_input_mode {
+ Some(InputMode::EnterSearch) => {
+ if !(raw_bytes == BRACKETED_PASTE_BEGIN
+ || raw_bytes == BRACKETED_PASTE_END)
+ {
+ if let Err(e) =
+ tab.update_search_term(raw_bytes, client_id)
+ {
+ log::error!("{}", e);
+ }
+ }
+ state_changed = true;
+ },
+ Some(InputMode::RenamePane) => {
+ if !(raw_bytes == BRACKETED_PASTE_BEGIN
+ || raw_bytes == BRACKETED_PASTE_END)
+ {
+ if let Err(e) =
+ tab.update_active_pane_name(raw_bytes, client_id)
+ {
+ log::error!("{}", e);
+ }
+ state_changed = true;
+ }
+ },
+ _ => {
+ let write_result = match tab.is_sync_panes_active() {
+ true => tab.write_to_terminals_on_current_tab(
+ &key_with_modifier,
+ raw_bytes,
+ is_kitty_keyboard_protocol,
+ client_id,
+ ),
+ false => tab.write_to_active_terminal(
+ &key_with_modifier,
+ raw_bytes,
+ is_kitty_keyboard_protocol,
+ client_id,
+ ),
+ };
+ if let Ok(true) = write_result {
+ state_changed = true;
+ }
+ },
+ }
+ }
+ );
+ },
+ };
+ if state_changed {
+ screen.log_and_report_session_state()?;
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::Resize(
+ client_id,
+ strategy,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.resize(client_id, strategy),
+ ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::SwitchFocus(client_id, _completion_tx) => {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.focus_next_pane(client_id)
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ screen.report_key_passthrough_state(client_id, old, new);
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::FocusNextPane(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to change focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to change focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.focus_next_pane(client_id)
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ screen.report_key_passthrough_state(client_id, old, new);
+ }
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::FocusPreviousPane(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to change focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to change focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.focus_previous_pane(client_id)
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ screen.report_key_passthrough_state(client_id, old, new);
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::FocusLastPane(client_id, _completion_tx) => {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.focus_last_pane(client_id)
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ screen.report_key_passthrough_state(client_id, old, new);
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MoveFocusLeft(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to move focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to move focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_focus_left(client_id),
+ ?
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ if old == new {
+ screen.bubble_focus_to_host(client_id, Direction::Left);
+ } else {
+ screen.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(Direction::Left),
+ );
+ }
+ }
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.add_active_pane_to_group_if_marking(&client_id);
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::MoveFocusLeftOrPreviousTab(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to move focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to move focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ screen.move_focus_left_or_previous_tab(client_id)?;
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ if old != new {
+ screen.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(Direction::Left),
+ );
+ }
+ }
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.add_active_pane_to_group_if_marking(&client_id);
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::MoveFocusDown(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to move focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to move focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_focus_down(client_id),
+ ?
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ if old == new {
+ screen.bubble_focus_to_host(client_id, Direction::Down);
+ } else {
+ screen.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(Direction::Down),
+ );
+ }
+ }
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.add_active_pane_to_group_if_marking(&client_id);
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::MoveFocusRight(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to move focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to move focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_focus_right(client_id),
+ ?
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ if old == new {
+ screen.bubble_focus_to_host(client_id, Direction::Right);
+ } else {
+ screen.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(Direction::Right),
+ );
+ }
+ }
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.add_active_pane_to_group_if_marking(&client_id);
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::MoveFocusRightOrNextTab(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to move focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to move focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ screen.move_focus_right_or_next_tab(client_id)?;
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ if old != new {
+ screen.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(Direction::Right),
+ );
+ }
+ }
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.add_active_pane_to_group_if_marking(&client_id);
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::MoveFocusUp(client_id, mut _completion_tx) => {
+ if screen.get_first_client_id().is_none() {
+ log::error!("No connected clients to move focus for");
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message("No connected clients to move focus for".to_string());
+ }
+ } else {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_focus_up(client_id),
+ ?
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ if old == new {
+ screen.bubble_focus_to_host(client_id, Direction::Up);
+ } else {
+ screen.report_key_passthrough_state_with_direction(
+ client_id,
+ old,
+ new,
+ Some(Direction::Up),
+ );
+ }
+ }
+ screen.clear_bell_for_focused_pane(client_id);
+ screen.add_active_pane_to_group_if_marking(&client_id);
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::ClearScreen(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.clear_active_terminal_screen(
+ client_id,
+ ),
+ ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::DumpScreen(
+ file,
+ client_id,
+ full,
+ pane_id,
+ completion_tx,
+ cli_client_id,
+ ansi,
+ ) => {
+ match file {
+ Some(file_path) => {
+ // Write dump to file (existing behavior)
+ match pane_id {
+ Some(pane_id) => {
+ for tab in screen.get_tabs_mut().values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if ansi {
+ tab.dump_with_ansi_terminal_screen(
+ Some(file_path.clone()),
+ pane_id,
+ full,
+ )?;
+ } else {
+ tab.dump_terminal_screen(
+ Some(file_path.clone()),
+ pane_id,
+ full,
+ )?;
+ }
+ break;
+ }
+ }
+ },
+ None => {
+ if ansi {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.dump_with_ansi_active_terminal_screen(
+ Some(file_path.to_string()),
+ client_id,
+ full
+ ),
+ ?
+ );
+ } else {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.dump_active_terminal_screen(
+ Some(file_path.to_string()),
+ client_id,
+ full
+ ),
+ ?
+ );
+ }
+ },
+ }
+ screen.render(None)?;
+ drop(completion_tx);
+ },
+ None => {
+ // Dump to STDOUT via Log
+ let dump = match pane_id {
+ Some(pane_id) => {
+ let mut result = String::new();
+ for tab in screen.get_tabs_mut().values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if ansi {
+ if let Some(dump) = tab
+ .get_dump_with_ansi_terminal_screen(pane_id, full)
+ {
+ result = dump;
+ }
+ } else {
+ if let Some(dump) =
+ tab.get_dump_terminal_screen(pane_id, full)
+ {
+ result = dump;
+ }
+ }
+ break;
+ }
+ }
+ result
+ },
+ None => {
+ let mut result = String::new();
+ if ansi {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| {
+ result = tab.get_dump_with_ansi_active_terminal_screen(client_id, full);
+ Ok::<(), anyhow::Error>(())
+ },
+ ?
+ );
+ } else {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| {
+ result = tab.get_dump_active_terminal_screen(client_id, full);
+ Ok::<(), anyhow::Error>(())
+ },
+ ?
+ );
+ }
+ result
+ },
+ };
+ screen.bus.senders.send_to_server(ServerInstruction::Log(
+ vec![dump],
+ cli_client_id.unwrap_or(client_id),
+ completion_tx,
+ ))?;
+ },
+ }
+ },
+ ScreenInstruction::DumpLayout(default_shell, client_id, completion_tx) => {
+ let err_context = || format!("Failed to dump layout");
+ let session_layout_metadata = screen.get_layout_metadata(default_shell, None);
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::DumpLayout(
+ session_layout_metadata,
+ client_id,
+ completion_tx,
+ ))
+ .with_context(err_context)?;
+ },
+ ScreenInstruction::ListClientsMetadata(default_shell, client_id, completion_tx) => {
+ let err_context = || format!("Failed to dump layout");
+ let session_layout_metadata = screen.get_layout_metadata(default_shell, None);
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ListClientsMetadata(
+ session_layout_metadata,
+ client_id,
+ completion_tx,
+ ))
+ .with_context(err_context)?;
+ },
+ ScreenInstruction::ListPanes {
+ show_all,
+ response_channel,
+ } => {
+ let err_context = || "Failed to list panes";
+ let pane_entries = screen
+ .collect_pane_list(show_all)
+ .with_context(err_context)?;
+ let _ = response_channel.send(pane_entries);
+ },
+ ScreenInstruction::ListTabs {
+ client_id,
+ response_channel,
+ } => {
+ let err_context = || "Failed to list tabs";
+ let tab_infos = screen
+ .collect_tab_list(client_id)
+ .with_context(err_context)?;
+ let _ = response_channel.send(tab_infos);
+ },
+ ScreenInstruction::GetCurrentTabInfo {
+ client_id,
+ response_channel,
+ } => {
+ let err_context = || "Failed to get current tab info";
+ let tab_info = screen
+ .get_current_tab_info(client_id)
+ .with_context(err_context)?;
+ let _ = response_channel.send(tab_info);
+ },
+ ScreenInstruction::DumpLayoutToPlugin {
+ plugin_id,
+ tab_index,
+ response_channel,
+ } => {
+ let err_context = || format!("Failed to dump layout");
+ let session_layout_metadata =
+ screen.get_layout_metadata(Some(screen.default_shell.clone()), tab_index);
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::DumpLayoutToPlugin {
+ session_layout_metadata,
+ plugin_id,
+ response_channel,
+ })
+ .with_context(err_context)
+ .non_fatal();
+ },
+ ScreenInstruction::GetFocusedPaneInfo {
+ client_id,
+ response_channel,
+ } => {
+ let response = match screen.active_tab_ids.get(&client_id) {
+ Some(&focused_tab_index) => match screen.get_active_pane_id(&client_id) {
+ Some(focused_pane_id) => GetFocusedPaneInfoResponse::Ok {
+ tab_index: focused_tab_index,
+ pane_id: focused_pane_id.into(),
+ },
+ None => GetFocusedPaneInfoResponse::Err(format!(
+ "No active pane found for client {:?}",
+ client_id
+ )),
+ },
+ None => GetFocusedPaneInfoResponse::Err(format!(
+ "Client {:?} not found in active_tab_indices",
+ client_id
+ )),
+ };
+ let _ = response_channel.send(response);
+ },
+ ScreenInstruction::GetPaneInfo {
+ pane_id,
+ response_channel,
+ } => {
+ let pane_info = screen.get_pane_info(pane_id);
+ let _ = response_channel.send(pane_info);
+ },
+ ScreenInstruction::GetTabInfo {
+ tab_id,
+ response_channel,
+ } => {
+ let tab_info = screen.get_tab_info(tab_id);
+ let _ = response_channel.send(tab_info);
+ },
+ ScreenInstruction::ListClientsToPlugin(plugin_id, client_id) => {
+ let err_context = || format!("Failed to dump layout");
+ let session_layout_metadata =
+ screen.get_layout_metadata(Some(screen.default_shell.clone()), None);
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::ListClientsToPlugin(
+ session_layout_metadata,
+ plugin_id,
+ client_id,
+ ))
+ .with_context(err_context)
+ .non_fatal();
+ },
+ ScreenInstruction::EditScrollback(client_id, ansi, completion_tx) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| {
+ if ansi {
+ tab.edit_scrollback_raw(client_id, completion_tx)
+ } else {
+ tab.edit_scrollback(client_id, completion_tx)
+ }
+ },
+ ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::GetPaneScrollback {
+ pane_id,
+ client_id,
+ get_full_scrollback,
+ response_channel,
+ } => {
+ let mut pane_contents: Option<PaneContents> = None;
+ 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, None));
+ break;
+ }
+ }
+ // Send response back through channel
+ let response = match pane_contents {
+ Some(contents) => PaneScrollbackResponse::Ok(contents),
+ None => {
+ log::warn!(
+ "Plugin requested scrollback for pane {:?} but pane was not found",
+ pane_id
+ );
+ PaneScrollbackResponse::Err(format!("Pane {:?} not found", pane_id))
+ },
+ };
+ if let Err(_) = response_channel.send(response) {
+ // the plugin likely timed out and dropped the receiver
+ log::debug!(
+ "Plugin timed out before pane scrollback response was sent for pane {:?}",
+ pane_id
+ );
+ }
+ },
+ ScreenInstruction::ScrollUp(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.scroll_active_terminal_up(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::MovePane(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_active_pane(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneBackwards(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_backwards(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneDown(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_down(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneUp(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_up(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneRight(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_right(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneLeft(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.move_active_pane_left(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::ScrollUpAt(
+ point,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .handle_scrollwheel_up(&point, 3, client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollDown(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.scroll_active_terminal_down(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollDownAt(
+ point,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .handle_scrollwheel_down(&point, 3, client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollToBottom(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .scroll_active_terminal_to_bottom(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollToTop(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .scroll_active_terminal_to_top(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::PageScrollUp(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .scroll_active_terminal_up_page(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::PageScrollDown(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .scroll_active_terminal_down_page(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::HalfPageScrollUp(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .scroll_active_terminal_up_half_page(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::HalfPageScrollDown(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .scroll_active_terminal_down_half_page(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearScroll(client_id) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .clear_active_terminal_scroll(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::CloseFocusedPane(client_id, completion_tx) => {
+ let old_pane_id = screen.get_active_pane_id(&client_id);
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.close_focused_pane(client_id, completion_tx), ?
+ );
+ let new_pane_id = screen.get_active_pane_id(&client_id);
+ if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
+ screen.report_key_passthrough_state(client_id, old, new);
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::SetSelectable(pid, selectable) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found_plugin = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pid) {
+ tab.set_pane_selectable(pid, selectable);
+ found_plugin = true;
+ break;
+ }
+ }
+ if !found_plugin {
+ pending_events_waiting_for_tab
+ .push(ScreenInstruction::SetSelectable(pid, selectable));
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::ShowPluginCursor(pid, client_id, cursor_position) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found_plugin = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_plugin(pid) {
+ tab.show_plugin_cursor(pid, client_id, cursor_position);
+ found_plugin = true;
+ break;
+ }
+ }
+ if !found_plugin {
+ pending_events_waiting_for_tab.push(ScreenInstruction::ShowPluginCursor(
+ pid,
+ client_id,
+ cursor_position,
+ ));
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::SetMouseSelectionSupport(pid, selection_support) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found_plugin = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pid) {
+ tab.set_mouse_selection_support(pid, selection_support);
+ found_plugin = true;
+ break;
+ }
+ }
+ if !found_plugin {
+ pending_events_waiting_for_tab.push(
+ ScreenInstruction::SetMouseSelectionSupport(pid, selection_support),
+ );
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::ClosePane(
+ id,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ exit_status,
+ ) => {
+ match client_id {
+ Some(client_id) => {
+ active_tab!(screen, client_id, |tab: &mut Tab| tab.close_pane(
+ id,
+ false,
+ exit_status
+ ));
+ },
+ None => {
+ let mut found = false;
+ for tab in screen.tabs.values_mut() {
+ if tab.get_all_pane_ids().contains(&id) {
+ tab.close_pane(id, false, exit_status);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ pending_events_waiting_for_pane
+ .entry(id)
+ .or_default()
+ .push(ScreenInstruction::ClosePane(id, None, None, exit_status));
+ }
+ },
+ }
+
+ screen.clear_nested_guest(id);
+
+ // Clean up PTY-side resources (async reader task, child PID mapping,
+ // terminal_id_to_raw_fd entry). This is needed because the natural
+ // child exit path (quit_cb) only sends ScreenInstruction::ClosePane
+ // and never sends PtyInstruction::ClosePane. The handler in Pty is
+ // idempotent, so this is safe even if ClosePane was already sent.
+ let _ = screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::ClosePane(id, None));
+
+ screen.log_and_report_session_state().non_fatal();
+ screen.retain_only_existing_panes_in_pane_groups();
+ },
+ ScreenInstruction::HoldPane(id, exit_status, run_command) => {
+ let is_first_run = false;
+ let mut found = false;
+ for tab in screen.tabs.values_mut() {
+ if tab.get_all_pane_ids().contains(&id) {
+ tab.hold_pane(id, exit_status, is_first_run, run_command.clone());
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ pending_events_waiting_for_pane
+ .entry(id)
+ .or_default()
+ .push(ScreenInstruction::HoldPane(id, exit_status, run_command));
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::UpdatePaneName(
+ c,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.update_active_pane_name(c, client_id), ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::UndoRenamePane(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.undo_active_rename_pane(client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleActiveTerminalFullscreen(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .toggle_active_pane_fullscreen(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(client_id, _completion_tx) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .toggle_active_pane_no_ui_fullscreen(client_id)
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::TogglePaneFrames(
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.pane_frame_style = match screen.pane_frame_style {
+ PaneFrameStyle::Full => PaneFrameStyle::Titles,
+ PaneFrameStyle::Titles => PaneFrameStyle::None,
+ PaneFrameStyle::None => PaneFrameStyle::Full,
+ };
+ for tab in screen.tabs.values_mut() {
+ tab.set_pane_frames(screen.pane_frame_style);
+ tab.update_input_modes()?;
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::SetPaneFrameStyle(pane_frame_style, _completion_tx) => {
+ screen.pane_frame_style = pane_frame_style;
+ for tab in screen.tabs.values_mut() {
+ tab.set_pane_frames(screen.pane_frame_style);
+ tab.update_input_modes()?;
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::SwitchTabNext(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.switch_tab_next(None, true, client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::SwitchTabPrev(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.switch_tab_prev(None, true, client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::CloseTab(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.close_tab(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::NewTab(
+ cwd,
+ default_shell,
+ layout,
+ floating_panes_layout,
+ tab_name,
+ (swap_tiled_layouts, swap_floating_layouts),
+ initial_panes,
+ block_on_first_terminal,
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ completion_tx,
+ ) => {
+ let tab_index = screen.get_new_tab_id();
+ pending_tab_ids.insert(tab_index);
+ let client_id_for_new_tab = if should_change_focus_to_new_tab {
+ Some(client_id)
+ } else {
+ None
+ };
+ let resolved_swap_layouts = (
+ swap_tiled_layouts
+ .unwrap_or_else(|| screen.default_layout.swap_tiled_layouts.clone()),
+ swap_floating_layouts
+ .unwrap_or_else(|| screen.default_layout.swap_floating_layouts.clone()),
+ );
+ screen.new_tab(
+ tab_index,
+ resolved_swap_layouts,
+ tab_name.clone(),
+ client_id_for_new_tab,
+ )?;
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::NewTab(
+ cwd,
+ default_shell,
+ layout,
+ floating_panes_layout,
+ tab_index,
+ initial_panes,
+ block_on_first_terminal,
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ completion_tx,
+ ))?;
+ },
+ ScreenInstruction::ApplyLayout(
+ layout,
+ floating_panes_layout,
+ new_pane_pids,
+ new_floating_pane_pids,
+ new_plugin_ids,
+ tab_id,
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ mut completion_tx,
+ blocking_terminal,
+ ) => {
+ log::info!(
+ "ScreenInstruction::ApplyLayout: applying layout for tab {}",
+ tab_id
+ );
+ // tab_id is a stable identifier from NewTab instruction
+ if let Some(first_terminal_pane) = new_pane_pids.iter().next() {
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_pane_id(PaneId::Terminal(first_terminal_pane.0)));
+ } else if let Some(plugin_id) =
+ new_plugin_ids.values().next().and_then(|v| v.first())
+ {
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_pane_id(PaneId::Plugin(*plugin_id)));
+ }
+ // Set the affected tab ID for plugin API return value
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_tab_id(tab_id));
+ screen.apply_layout(
+ layout,
+ floating_panes_layout,
+ new_pane_pids.clone(),
+ new_floating_pane_pids,
+ new_plugin_ids.clone(),
+ tab_id,
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ blocking_terminal,
+ )?;
+ pending_tab_ids.remove(&tab_id);
+ if pending_tab_ids.is_empty() {
+ for (tab_index, client_id) in pending_tab_switches.drain() {
+ screen.go_to_tab(tab_index as usize + 1, client_id)?;
+ }
+ if should_change_focus_to_new_tab {
+ // Convert ID → position for go_to_tab (which expects 1-based position)
+ if let Some(tab_position) = screen.get_tab_position_by_id(tab_id) {
+ screen.go_to_tab(tab_position + 1, client_id)?;
+ }
+ }
+ } else if should_change_focus_to_new_tab {
+ let client_id_to_switch = if screen.active_tab_ids.contains_key(&client_id) {
+ Some(client_id)
+ } else {
+ screen.active_tab_ids.keys().next().copied()
+ };
+ if let Some(client_id_to_switch) = client_id_to_switch {
+ // Convert ID → position for pending_tab_switches (which stores positions)
+ if let Some(tab_position) = screen.get_tab_position_by_id(tab_id) {
+ pending_tab_switches.insert((tab_position, client_id_to_switch));
+ }
+ }
+ }
+
+ for plugin_ids in new_plugin_ids.values() {
+ for plugin_id in plugin_ids {
+ if let Some(loading_indication) =
+ plugin_loading_message_cache.remove(plugin_id)
+ {
+ screen.update_plugin_loading_stage(*plugin_id, loading_indication);
+ screen.render(None)?;
+ }
+ screen.render_blocker.register_blocking_plugin(*plugin_id);
+ }
+ }
+
+ for event in pending_events_waiting_for_client.drain(..) {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+
+ for event in pending_events_waiting_for_tab.drain(..) {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+
+ screen.render(None)?;
+ // we do this here in order to recover from a race condition on app start
+ // that sometimes causes Zellij to think the terminal window is a different size
+ // than it actually is - here, we query the client for its terminal size after
+ // we've finished the setup and handle it as we handle a normal resize,
+ // while this can affect other instances of a layout being applied, the query is
+ // very short and cheap and shouldn't cause any trouble
+ if let Some(os_input) = &mut screen.bus.os_input {
+ for (client_id, _is_web_client) in screen.connected_clients.borrow().iter() {
+ log::info!(
+ "ApplyLayout: sending QueryTerminalSize to client {}",
+ client_id
+ );
+ let _ = os_input
+ .send_to_client(*client_id, ServerToClientMsg::QueryTerminalSize);
+ }
+ }
+ },
+ ScreenInstruction::GoToTab(
+ tab_index,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ let client_id_to_switch = if client_id.is_none() {
+ None
+ } else if screen
+ .active_tab_ids
+ .contains_key(&client_id.expect("This is checked above"))
+ {
+ client_id
+ } else {
+ screen.active_tab_ids.keys().next().copied()
+ };
+ match client_id_to_switch {
+ // we must make sure pending_tab_ids is empty because otherwise we cannot be
+ // sure this instruction is applied at the right time (eg. we might have a
+ // pending tab that will become not-pending after this instruction and change
+ // the client focus, which should have happened before this instruction and not
+ // after)
+ Some(client_id) if pending_tab_ids.is_empty() => {
+ screen.go_to_tab(tab_index as usize, client_id)?;
+ screen.render(None)?;
+ },
+ _ => {
+ if let Some(client_id) = client_id {
+ pending_tab_switches.insert((tab_index as usize, client_id));
+ }
+ },
+ }
+ },
+ ScreenInstruction::GoToTabName(
+ tab_name,
+ default_shell,
+ create,
+ client_id,
+ mut completion_tx,
+ ) => {
+ let swap_layouts = (
+ screen.default_layout.swap_tiled_layouts.clone(),
+ screen.default_layout.swap_floating_layouts.clone(),
+ );
+ let client_id = if client_id.is_none() {
+ None
+ } else if screen
+ .active_tab_ids
+ .contains_key(&client_id.expect("This is checked above"))
+ {
+ client_id
+ } else {
+ screen.active_tab_ids.keys().next().copied()
+ };
+ if let Some(client_id) = client_id {
+ let is_web_client = screen
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+ if let Ok(tab_exists) = screen.go_to_tab_name(tab_name.clone(), client_id) {
+ screen.render(None)?;
+ if tab_exists {
+ // Tab already exists - find its ID and set in completion
+ if let Some(existing_tab) =
+ screen.tabs.values().find(|t| t.name == tab_name)
+ {
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_tab_id(existing_tab.id));
+ }
+ }
+ if create && !tab_exists {
+ let tab_index = screen.get_new_tab_id();
+ let should_change_focus_to_new_tab = true;
+ screen.new_tab(
+ tab_index,
+ swap_layouts,
+ Some(tab_name),
+ Some(client_id),
+ )?;
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::NewTab(
+ None,
+ default_shell,
+ None,
+ vec![],
+ tab_index,
+ None, // initial_panes
+ false, // block_on_first_terminal
+ should_change_focus_to_new_tab,
+ (client_id, is_web_client),
+ completion_tx,
+ ))?;
+ continue; // so we don't get to the completion signalling below
+ }
+ }
+ }
+ },
+ ScreenInstruction::UpdateTabName(
+ c,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.update_active_tab_name(c, client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::UndoRenameTab(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.undo_active_rename_tab(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::MoveTabLeft(client_id, completion_tx) => {
+ if pending_tab_ids.is_empty() {
+ screen.move_active_tab_to_left(client_id)?;
+ screen.render(None)?;
+ } else {
+ // Defer execution, forward completion_tx
+ pending_events_waiting_for_tab
+ .push(ScreenInstruction::MoveTabLeft(client_id, completion_tx));
+ }
+ },
+ ScreenInstruction::MoveTabRight(client_id, completion_tx) => {
+ if pending_tab_ids.is_empty() {
+ screen.move_active_tab_to_right(client_id)?;
+ screen.render(None)?;
+ } else {
+ // Defer execution, forward completion_tx
+ pending_events_waiting_for_tab
+ .push(ScreenInstruction::MoveTabRight(client_id, completion_tx));
+ }
+ },
+ ScreenInstruction::TerminalResize(new_size) => {
+ screen.resize_to_screen(new_size)?;
+ screen.log_and_report_session_state()?; // update tabs so that the ui indication will be send to the plugins
+ screen.render(None)?;
+ },
+ ScreenInstruction::RecomputeTabSize(client_id, new_size) => {
+ screen.set_client_size(client_id, new_size);
+ let active_tab_id = screen.active_tab_ids.get(&client_id).copied();
+ if let Some(tab_id) = active_tab_id {
+ screen.recompute_tab_size(tab_id)?;
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::TerminalPixelDimensions(pixel_dimensions) => {
+ screen.update_pixel_dimensions(pixel_dimensions);
+ },
+ ScreenInstruction::TerminalBackgroundColor(background_color_instruction) => {
+ screen.update_terminal_background_color(background_color_instruction);
+ },
+ ScreenInstruction::TerminalForegroundColor(background_color_instruction) => {
+ screen.update_terminal_foreground_color(background_color_instruction);
+ },
+ ScreenInstruction::TerminalColorRegisters(color_registers) => {
+ screen.update_terminal_color_registers(color_registers);
+ },
+ ScreenInstruction::SetKittyGraphicsSupport {
+ client_id,
+ supported,
+ } => {
+ screen.update_kitty_graphics_support(client_id, supported);
+ },
+ ScreenInstruction::ForwardHostQuery { pane_id, query } => {
+ screen.forward_host_query(pane_id, query);
+ },
+ ScreenInstruction::NestedSessionMessageFromPane { pane_id, message } => {
+ screen.handle_nested_session_message_from_pane(pane_id, message);
+ },
+ ScreenInstruction::NestedGuestPingTick { pane_id } => {
+ screen.handle_nested_guest_ping_tick(pane_id);
+ },
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message } => {
+ screen.handle_nested_session_message_from_host(client_id, message);
+ },
+ ScreenInstruction::GuestModalChoice {
+ client_id,
+ pane_id,
+ outcome,
+ } => {
+ screen.handle_guest_modal_choice(client_id, pane_id, outcome);
+ },
+ ScreenInstruction::ForwardedReplyFromHost { token, reply_bytes } => {
+ screen.handle_forwarded_reply_from_host(token, reply_bytes)?;
+ // The handler's replay of `pending_pty_input` mutates the
+ // grid. Without a render scheduling here the clients
+ // keep displaying the pre-reply frame
+ screen.render(None)?;
+ },
+ ScreenInstruction::ResumePaneAfterForward {
+ pane_id,
+ reply_bytes,
+ } => {
+ screen.resume_pane_after_forward(pane_id, reply_bytes)?;
+ // Same rationale as ForwardedReplyFromHost above — the
+ // resume's replay paints the grid, so we must schedule
+ // a render before yielding back to the screen loop.
+ screen.render(None)?;
+ },
+ ScreenInstruction::HostTerminalThemeChanged(mode) => {
+ screen.update_host_terminal_theme_mode(mode)?;
+ },
+ ScreenInstruction::SetDarkTheme(mut completion_tx) => {
+ screen.apply_manual_host_terminal_theme_mode(
+ HostTerminalThemeMode::Dark,
+ &mut completion_tx,
+ )?;
+ },
+ ScreenInstruction::SetLightTheme(mut completion_tx) => {
+ screen.apply_manual_host_terminal_theme_mode(
+ HostTerminalThemeMode::Light,
+ &mut completion_tx,
+ )?;
+ },
+ ScreenInstruction::ToggleTheme(mut completion_tx) => {
+ let next = match screen.host_terminal_theme_mode {
+ Some(HostTerminalThemeMode::Dark) => HostTerminalThemeMode::Light,
+ Some(HostTerminalThemeMode::Light) => HostTerminalThemeMode::Dark,
+ // No prior mode: treat as Dark and toggle to Light.
+ None => HostTerminalThemeMode::Light,
+ };
+ screen.apply_manual_host_terminal_theme_mode(next, &mut completion_tx)?;
+ },
+ ScreenInstruction::ChangeMode(
+ input_mode,
+ base_mode,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.change_mode(input_mode, base_mode, client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ChangeModeForAllClients(
+ input_mode,
+ base_mode,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.change_mode_for_all_clients(input_mode, base_mode)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleActiveSyncTab(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, _client_id: ClientId| tab.toggle_sync_panes_is_active()
+ );
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::MouseEvent(
+ event,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.handle_mouse_event(event, client_id);
+ },
+ ScreenInstruction::Copy(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab!(screen, client_id, |tab: &mut Tab| tab
+ .copy_selection(client_id), ?);
+ screen.render(None)?;
+ },
+ ScreenInstruction::Exit => {
+ break;
+ },
+ ScreenInstruction::ToggleTab(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.toggle_tab(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::AddClient(
+ client_id,
+ is_web_client,
+ client_size,
+ tab_position_to_focus,
+ pane_id_to_focus,
+ ) => {
+ screen.set_client_size(client_id, client_size);
+ screen.add_client(client_id, is_web_client)?;
+ let pane_id = pane_id_to_focus.map(|(pane_id, is_plugin)| {
+ if is_plugin {
+ PaneId::Plugin(pane_id)
+ } else {
+ PaneId::Terminal(pane_id)
+ }
+ });
+ if let Some(pane_id) = pane_id {
+ screen.focus_pane_with_id(pane_id, true, false, client_id)?;
+ } else if let Some(tab_position_to_focus) = tab_position_to_focus {
+ screen.go_to_tab(tab_position_to_focus, client_id)?;
+ }
+ screen.show_guest_modals_for_new_client(client_id);
+ let focused_pane_id = screen.get_active_pane_id(&client_id);
+ if let Some(focused) = focused_pane_id {
+ screen.report_key_passthrough_state(client_id, focused, focused);
+ }
+ for event in pending_events_waiting_for_client.drain(..) {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+ screen.log_and_report_session_state()?;
+
+ if is_web_client {
+ // we do this because
+ // we need to query the client for its size, and we must do it only after we've
+ // added it to our state.
+ //
+ // we have to do this specifically for web clients because the browser (as opposed
+ // to a traditional terminal) can only figure out its dimensions after we sent it relevant
+ // state (eg. font, which is controlled by our config and it needs to determine cell size)
+ if let Some(os_input) = &mut screen.bus.os_input {
+ let _ = os_input
+ .send_to_client(client_id, ServerToClientMsg::QueryTerminalSize);
+ }
+ }
+
+ screen.render(None)?;
+ },
+ ScreenInstruction::RemoveClient(client_id) => {
+ screen.remove_client(client_id).non_fatal();
+ screen.log_and_report_session_state().non_fatal();
+ screen.render(None).non_fatal();
+ },
+ ScreenInstruction::SuppressRenderUntilMobile(client_id) => {
+ screen.mobile_render_gate.gate(client_id);
+ screen
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::MobileGateTimeout(client_id))
+ .non_fatal();
+ },
+ ScreenInstruction::MobileSizeSettled(client_id) => {
+ screen.mobile_render_gate.record_settled_size(client_id);
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+ if screen.try_lift_mobile_gate(client_id) {
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::ForceMobileUngate(client_id) => {
+ if screen.mobile_render_gate.is_gated(client_id) {
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+ screen.mobile_render_gate.ungate(client_id);
+ screen.force_render_mobile_tab(client_id);
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::UpdateSearch(
+ c,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.update_search_term(c, client_id), ?
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::SearchDown(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.search_down(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::SearchUp(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.search_up(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::SearchToggleCaseSensitivity(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .toggle_search_case_sensitivity(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::SearchToggleWrap(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.toggle_search_wrap(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::SearchToggleWholeWord(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab.toggle_search_whole_words(client_id)
+ );
+ screen.render(None)?;
+ },
+ ScreenInstruction::AddRedPaneFrameColorOverride(pane_ids, error_text) => {
+ let all_tabs = screen.get_tabs_mut();
+ for pane_id in pane_ids {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.add_red_pane_frame_color_override(pane_id, error_text.clone());
+ break;
+ }
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::AddHighlightPaneFrameColorOverride(pane_ids, error_text) => {
+ let all_tabs = screen.get_tabs_mut();
+ for pane_id in pane_ids {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.add_highlight_pane_frame_color_override(
+ pane_id,
+ error_text.clone(),
+ None,
+ );
+ break;
+ }
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearPaneFrameColorOverride(pane_ids) => {
+ let all_tabs = screen.get_tabs_mut();
+ for pane_id in pane_ids {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_pane_frame_color_override(pane_id, None);
+ break;
+ }
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::SetTabBellFlash(tab_id, is_flashing) => {
+ if let Some(tab) = screen.tabs.values_mut().find(|t| t.id == tab_id) {
+ tab.tab_bell_flash = is_flashing;
+ tab.clear_tab_bell_ring();
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::PreviousSwapLayout(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, _client_id: ClientId| tab.previous_swap_layout(),
+ ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::NextSwapLayout(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, _client_id: ClientId| tab.next_swap_layout(),
+ ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::OverrideLayout(
+ cwd,
+ default_shell,
+ mut tab_layouts,
+ retain_existing_terminal_panes,
+ retain_existing_plugin_panes,
+ apply_only_to_focused_tab,
+ client_id,
+ completion_tx,
+ ) => {
+ // 1. Determine which tabs to close (exist but not in layout)
+ let existing_tab_indices: HashSet<usize> = screen.tabs.keys().copied().collect();
+ let layout_tab_indices: HashSet<usize> =
+ tab_layouts.iter().map(|tl| tl.tab_index).collect();
+ let tabs_to_close: Vec<usize> = existing_tab_indices
+ .difference(&layout_tab_indices)
+ .copied()
+ .collect();
+
+ // 2. Process each tab layout
+ let mut processed_tab_layouts = Vec::new();
+
+ if apply_only_to_focused_tab {
+ match screen.get_active_tab_mut(client_id) {
+ Ok(active_tab) => {
+ if tab_layouts.is_empty() {
+ log::error!("No tab layouts found, cannot override.");
+ continue;
+ }
+ let mut tab_layout_info = tab_layouts.remove(0);
+ tab_layout_info.tab_index = active_tab.id;
+ // Set the tab name if provided
+ if let Some(name) = tab_layout_info.tab_name.take() {
+ active_tab.name = name;
+ }
+
+ // Find already-running panes for this tab
+ let (tiled_to_ignore, floating_indices) = find_already_running_panes(
+ &tab_layout_info.tiled_layout,
+ &tab_layout_info.floating_layouts,
+ active_tab,
+ );
+
+ // Mark run instructions to ignore (prevents re-spawning)
+ for run_instruction in tiled_to_ignore {
+ tab_layout_info
+ .tiled_layout
+ .ignore_run_instruction(run_instruction);
+ }
+
+ for idx in floating_indices {
+ tab_layout_info
+ .floating_layouts
+ .get_mut(idx)
+ .map(|f| f.already_running = true);
+ }
+
+ processed_tab_layouts.push(tab_layout_info);
+ },
+ Err(e) => {
+ log::error!("Failed to override layout of active tab: {}", e);
+ },
+ }
+ } else {
+ for mut tab_layout_info in tab_layouts {
+ // Get the tab by index
+ let tab = match screen.tabs.get_mut(&tab_layout_info.tab_index) {
+ Some(t) => t,
+ None => {
+ // no corresponding tab exists, we'll create it
+ processed_tab_layouts.push(tab_layout_info);
+ continue;
+ },
+ };
+
+ // Set the tab name if provided
+ if let Some(name) = tab_layout_info.tab_name.take() {
+ tab.name = name;
+ }
+
+ // Find already-running panes for this tab
+ let (tiled_to_ignore, floating_indices) = find_already_running_panes(
+ &tab_layout_info.tiled_layout,
+ &tab_layout_info.floating_layouts,
+ tab,
+ );
+
+ // Mark run instructions to ignore (prevents re-spawning)
+ for run_instruction in tiled_to_ignore {
+ tab_layout_info
+ .tiled_layout
+ .ignore_run_instruction(run_instruction);
+ }
+
+ for idx in floating_indices {
+ tab_layout_info
+ .floating_layouts
+ .get_mut(idx)
+ .map(|f| f.already_running = true);
+ }
+
+ processed_tab_layouts.push(tab_layout_info);
+ }
+ }
+
+ // 3. Send to plugin thread with all tab layouts
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::OverrideLayout(
+ cwd,
+ default_shell,
+ processed_tab_layouts,
+ retain_existing_terminal_panes,
+ retain_existing_plugin_panes,
+ client_id,
+ completion_tx,
+ ))?;
+
+ // 4. Close tabs that aren't in the layout
+ if !apply_only_to_focused_tab {
+ for tab_index in tabs_to_close {
+ screen.close_tab_by_id(tab_index)?;
+ }
+ }
+ },
+ ScreenInstruction::OverrideLayoutComplete(
+ tab_results,
+ retain_existing_terminal_panes,
+ retain_existing_plugin_panes,
+ client_id,
+ completion_tx,
+ ) => {
+ // Process each tab result
+ for tab_result in tab_results {
+ if let Some(tab) = screen.tabs.get_mut(&tab_result.tab_index) {
+ if let Err(e) = tab.override_layout(
+ tab_result.tiled_layout,
+ tab_result.floating_layouts,
+ tab_result.swap_tiled_layouts.clone(),
+ tab_result.swap_floating_layouts.clone(),
+ tab_result.new_terminal_pids,
+ tab_result.new_floating_pane_pids,
+ tab_result.plugin_ids,
+ retain_existing_terminal_panes,
+ retain_existing_plugin_panes,
+ client_id,
+ None,
+ ) {
+ log::error!(
+ "Failed to override layout for tab {}: {:?}",
+ tab_result.tab_index,
+ e
+ );
+ }
+ } else {
+ // Tab doesn't exist - create it
+ let swap_layouts = (
+ tab_result.swap_tiled_layouts.clone().unwrap_or_default(),
+ tab_result.swap_floating_layouts.clone().unwrap_or_default(),
+ );
+ if let Err(e) = screen.new_tab(
+ tab_result.tab_index,
+ swap_layouts,
+ tab_result.tab_name.clone(),
+ None,
+ ) {
+ log::error!(
+ "Failed to create new tab {} during override completion: {:?}",
+ tab_result.tab_index,
+ e
+ );
+ continue;
+ }
+
+ // Now override the newly created tab's layout
+ if let Some(tab) = screen.tabs.get_mut(&tab_result.tab_index) {
+ if let Err(e) = tab.override_layout(
+ tab_result.tiled_layout,
+ tab_result.floating_layouts,
+ tab_result.swap_tiled_layouts,
+ tab_result.swap_floating_layouts,
+ tab_result.new_terminal_pids,
+ tab_result.new_floating_pane_pids,
+ tab_result.plugin_ids,
+ retain_existing_terminal_panes,
+ retain_existing_plugin_panes,
+ client_id,
+ None,
+ ) {
+ log::error!(
+ "Failed to override layout for new tab {}: {:?}",
+ tab_result.tab_index,
+ e
+ );
+ }
+ }
+ }
+ }
+
+ for event in pending_events_waiting_for_client.drain(..) {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+
+ for event in pending_events_waiting_for_tab.drain(..) {
+ screen.bus.senders.send_to_screen(event).non_fatal();
+ }
+
+ // Single render and log after all tabs are updated (performance optimization)
+ screen.log_and_report_session_state()?;
+ let _ = screen.render(None);
+ drop(completion_tx); // action ends here, notify the action initiator
+ },
+ ScreenInstruction::QueryTabNames(client_id, completion_tx) => {
+ let tab_names = screen
+ .get_tabs_mut()
+ .values()
+ .map(|tab| tab.name.clone())
+ .collect::<Vec<String>>();
+ screen.bus.senders.send_to_server(ServerInstruction::Log(
+ tab_names,
+ client_id,
+ completion_tx,
+ ))?;
+ },
+ ScreenInstruction::NewTiledPluginPane(
+ run_plugin,
+ pane_title,
+ skip_cache,
+ cwd,
+ client_id,
+ completion_tx,
+ explicit_tab_id,
+ no_focus,
+ ) => {
+ let tab_index = explicit_tab_id
+ .unwrap_or_else(|| *screen.active_tab_ids.values().next().unwrap_or(&1));
+ let size = Size::default();
+ let should_float = Some(false);
+ let should_be_opened_in_place = false;
+ let should_focus_plugin = if no_focus { Some(false) } else { None };
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ should_float,
+ should_be_opened_in_place,
+ false, // close_replaced_pane
+ pane_title,
+ run_plugin,
+ tab_index,
+ None,
+ client_id,
+ size,
+ skip_cache,
+ cwd,
+ should_focus_plugin,
+ None,
+ completion_tx,
+ ))?;
+ },
+ ScreenInstruction::NewFloatingPluginPane(
+ run_plugin,
+ pane_title,
+ skip_cache,
+ cwd,
+ floating_pane_coordinates,
+ client_id,
+ completion_tx,
+ explicit_tab_id,
+ no_focus,
+ ) => {
+ let resolved_tab_index =
+ explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied());
+ match resolved_tab_index {
+ Some(tab_index) => {
+ let size = Size::default();
+ let should_float = Some(true);
+ let should_be_opened_in_place = false;
+ let should_focus_plugin = if no_focus { Some(false) } else { None };
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ should_float,
+ should_be_opened_in_place,
+ false, // close_replaced_pane
+ pane_title,
+ run_plugin,
+ tab_index,
+ None,
+ client_id,
+ size,
+ skip_cache,
+ cwd,
+ should_focus_plugin,
+ floating_pane_coordinates,
+ completion_tx,
+ ))?;
+ },
+ None => {
+ log::error!(
+ "Could not find an active tab - is there at least 1 connected user?"
+ );
+ },
+ }
+ },
+ ScreenInstruction::NewInPlacePluginPane(
+ run_plugin,
+ pane_title,
+ pane_id_to_replace,
+ skip_cache,
+ close_replaced_pane,
+ client_id,
+ completion_tx,
+ explicit_tab_id,
+ no_focus,
+ ) => {
+ let resolved_tab_index =
+ explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied());
+ match resolved_tab_index {
+ Some(tab_index) => {
+ let size = Size::default();
+ let should_float = None;
+ let should_be_in_place = true;
+ let should_focus_plugin = if no_focus { Some(false) } else { None };
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ should_float,
+ should_be_in_place,
+ close_replaced_pane,
+ pane_title,
+ run_plugin,
+ tab_index,
+ Some(pane_id_to_replace),
+ client_id,
+ size,
+ skip_cache,
+ None,
+ should_focus_plugin,
+ None,
+ completion_tx,
+ ))?;
+ },
+ None => {
+ log::error!(
+ "Could not find an active tab - is there at least 1 connected user?"
+ );
+ },
+ }
+ },
+ ScreenInstruction::StartOrReloadPluginPane(run_plugin, pane_title, completion_tx) => {
+ let tab_index = screen.active_tab_ids.values().next().unwrap_or(&1);
+ let size = Size::default();
+ let should_float = Some(false);
+
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::Reload(
+ should_float,
+ pane_title,
+ run_plugin,
+ *tab_index,
+ size,
+ completion_tx,
+ ))?;
+ },
+ ScreenInstruction::AddPlugin(
+ should_float,
+ should_be_in_place,
+ close_replaced_pane,
+ run_plugin_or_alias,
+ pane_title,
+ tab_index,
+ plugin_id,
+ pane_id_to_replace,
+ cwd,
+ start_suppressed,
+ floating_pane_coordinates,
+ should_focus_plugin,
+ client_id,
+ mut completion_tx,
+ ) => {
+ let mut new_pane_placement = NewPanePlacement::default();
+ let maybe_should_float = should_float;
+ let should_be_tiled = maybe_should_float.map(|f| !f).unwrap_or(false);
+ let should_float = maybe_should_float.unwrap_or(false);
+ if floating_pane_coordinates.is_some() || should_float {
+ new_pane_placement = NewPanePlacement::with_floating_pane_coordinates(
+ floating_pane_coordinates.clone(),
+ );
+ }
+ if should_be_tiled {
+ new_pane_placement = NewPanePlacement::Tiled {
+ direction: None,
+ borderless: None,
+ };
+ }
+ if should_be_in_place {
+ new_pane_placement = NewPanePlacement::with_pane_id_to_replace(
+ pane_id_to_replace.map(|id| id.into()),
+ close_replaced_pane,
+ );
+ }
+ if screen.active_tab_ids.is_empty() && tab_index.is_none() {
+ pending_events_waiting_for_client.push(ScreenInstruction::AddPlugin(
+ maybe_should_float,
+ should_be_in_place,
+ close_replaced_pane,
+ run_plugin_or_alias,
+ pane_title,
+ tab_index,
+ plugin_id,
+ pane_id_to_replace,
+ cwd,
+ start_suppressed,
+ floating_pane_coordinates,
+ should_focus_plugin,
+ client_id,
+ completion_tx,
+ ));
+ continue;
+ }
+ let pane_title = pane_title.unwrap_or_else(|| {
+ format!(
+ "({}) - {}",
+ cwd.map(|cwd| cwd.display().to_string())
+ .unwrap_or(".".to_owned()),
+ run_plugin_or_alias.location_string()
+ )
+ });
+ let run_plugin = Run::Plugin(run_plugin_or_alias);
+
+ // Set affected pane ID for CLI client output
+ if let Some(ref mut completion) = completion_tx {
+ completion.set_affected_pane_id(PaneId::Plugin(plugin_id));
+ }
+
+ if should_be_in_place {
+ if let Some(pane_id_to_replace) = pane_id_to_replace {
+ let client_tab_index_or_pane_id =
+ ClientTabIndexOrPaneId::PaneId(pane_id_to_replace);
+ screen.replace_pane(
+ PaneId::Plugin(plugin_id),
+ None,
+ Some(run_plugin),
+ Some(pane_title),
+ close_replaced_pane,
+ client_tab_index_or_pane_id,
+ )?;
+ } else if let Some(client_id) = client_id {
+ let client_tab_index_or_pane_id =
+ ClientTabIndexOrPaneId::ClientId(client_id);
+ screen.replace_pane(
+ PaneId::Plugin(plugin_id),
+ None,
+ Some(run_plugin),
+ Some(pane_title),
+ close_replaced_pane,
+ client_tab_index_or_pane_id,
+ )?;
+ } else {
+ log::error!("Must have pane id to replace or connected client_id if replacing a pane");
+ }
+ } else if let Some(client_id) = client_id {
+ active_tab_and_connected_client_id!(screen, client_id, |active_tab: &mut Tab, _client_id: ClientId| {
+ active_tab.new_pane(
+ PaneId::Plugin(plugin_id),
+ Some(pane_title),
+ Some(run_plugin),
+ start_suppressed,
+ should_focus_plugin.unwrap_or(true),
+ new_pane_placement,
+ Some(client_id),
+ None,
+ )
+ }, ?);
+ } else if let Some(active_tab) =
+ tab_index.and_then(|tab_index| screen.tabs.get_mut(&tab_index))
+ {
+ active_tab.new_pane(
+ PaneId::Plugin(plugin_id),
+ Some(pane_title),
+ Some(run_plugin),
+ start_suppressed,
+ should_focus_plugin.unwrap_or(true),
+ new_pane_placement,
+ None,
+ None,
+ )?;
+ } else {
+ log::error!("Tab index not found: {:?}", tab_index);
+ }
+ if let Some(loading_indication) = plugin_loading_message_cache.remove(&plugin_id) {
+ screen.update_plugin_loading_stage(plugin_id, loading_indication);
+ screen.render(None)?;
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::UpdatePluginLoadingStage(pid, loading_indication) => {
+ if loading_indication.is_error() {
+ screen.ungate_clients_for_mobile_plugin(pid);
+ }
+ let found_plugin =
+ screen.update_plugin_loading_stage(pid, loading_indication.clone());
+ if !found_plugin {
+ plugin_loading_message_cache.insert(pid, loading_indication);
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::StartPluginLoadingIndication(pid, loading_indication) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_plugin(pid) {
+ tab.start_plugin_loading_indication(pid, loading_indication);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ProgressPluginLoadingOffset(pid) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_plugin(pid) {
+ tab.progress_plugin_loading_offset(pid);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::RequestStateUpdateForPlugins => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ tab.update_input_modes()?;
+ }
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::LaunchOrFocusPlugin(
+ run_plugin,
+ should_float,
+ move_to_focused_tab,
+ should_open_in_place,
+ close_replaced_pane,
+ pane_id_to_replace,
+ skip_cache,
+ client_id,
+ mut completion_tx,
+ explicit_tab_id,
+ ) => match pane_id_to_replace {
+ Some(pane_id_to_replace) if should_open_in_place => {
+ let resolved_tab_index =
+ explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied());
+ match resolved_tab_index {
+ Some(tab_index) => {
+ let size = Size::default();
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ Some(should_float),
+ should_open_in_place,
+ close_replaced_pane,
+ None,
+ run_plugin,
+ tab_index,
+ Some(pane_id_to_replace),
+ client_id,
+ size,
+ skip_cache,
+ None,
+ None,
+ None,
+ completion_tx,
+ ))?;
+ },
+ None => {
+ log::error!(
+ "Could not find an active tab - is there at least 1 connected user?"
+ );
+ },
+ }
+ },
+ _ => {
+ let client_id = if screen.active_tab_ids.contains_key(&client_id) {
+ Some(client_id)
+ } else {
+ screen.get_first_client_id()
+ };
+ let client_id_and_focused_tab = client_id.and_then(|client_id| {
+ screen
+ .active_tab_ids
+ .get(&client_id)
+ .map(|tab_index| (*tab_index, client_id))
+ });
+ let resolved_tab_and_client = explicit_tab_id
+ .and_then(|tid| client_id.map(|cid| (tid, cid)))
+ .or(client_id_and_focused_tab);
+ match resolved_tab_and_client {
+ Some((tab_index, client_id)) => {
+ if screen.focus_plugin_pane(
+ &run_plugin,
+ should_float,
+ move_to_focused_tab,
+ should_open_in_place,
+ client_id,
+ &mut completion_tx,
+ )? {
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ } else {
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ Some(should_float),
+ should_open_in_place,
+ close_replaced_pane,
+ None,
+ run_plugin,
+ tab_index,
+ None,
+ client_id,
+ Size::default(),
+ skip_cache,
+ None,
+ None,
+ None,
+ completion_tx,
+ ))?;
+ }
+ },
+ None => {
+ log::error!("No connected clients found - cannot load or focus plugin")
+ },
+ }
+ },
+ },
+ ScreenInstruction::LaunchPlugin(
+ run_plugin,
+ should_float,
+ should_open_in_place,
+ close_replaced_pane,
+ pane_id_to_replace,
+ skip_cache,
+ cwd,
+ client_id,
+ completion_tx,
+ explicit_tab_id,
+ no_focus,
+ ) => match pane_id_to_replace {
+ Some(pane_id_to_replace) => {
+ let resolved_tab_index =
+ explicit_tab_id.or_else(|| screen.active_tab_ids.values().next().copied());
+ match resolved_tab_index {
+ Some(tab_index) => {
+ let size = Size::default();
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ Some(should_float),
+ should_open_in_place,
+ close_replaced_pane,
+ None,
+ run_plugin,
+ tab_index,
+ Some(pane_id_to_replace),
+ client_id,
+ size,
+ skip_cache,
+ cwd,
+ if no_focus { Some(false) } else { None },
+ None,
+ completion_tx,
+ ))?;
+ },
+ None => {
+ log::error!(
+ "Could not find an active tab - is there at least 1 connected user?"
+ );
+ },
+ }
+ },
+ None => {
+ let client_id = if screen.active_tab_ids.contains_key(&client_id) {
+ Some(client_id)
+ } else {
+ screen.get_first_client_id()
+ };
+ let client_id_and_focused_tab = client_id.and_then(|client_id| {
+ screen
+ .active_tab_ids
+ .get(&client_id)
+ .map(|tab_index| (*tab_index, client_id))
+ });
+ let resolved_tab_and_client = explicit_tab_id
+ .and_then(|tid| client_id.map(|cid| (tid, cid)))
+ .or(client_id_and_focused_tab);
+ match resolved_tab_and_client {
+ Some((tab_index, client_id)) => {
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::FillPluginCwd(
+ Some(should_float),
+ should_open_in_place,
+ close_replaced_pane,
+ None,
+ run_plugin,
+ tab_index,
+ None,
+ client_id,
+ Size::default(),
+ skip_cache,
+ cwd,
+ if no_focus { Some(false) } else { None },
+ None,
+ completion_tx,
+ ))?;
+ },
+ None => {
+ log::error!("No connected clients found - cannot load or focus plugin")
+ },
+ }
+ },
+ },
+ ScreenInstruction::SuppressPane(pane_id, client_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_non_suppressed_pane_with_pid(&pane_id) {
+ tab.suppress_pane(pane_id, Some(client_id));
+ drop(screen.render(None));
+ break;
+ }
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::UnsuppressPane(pane_id, should_float_if_hidden) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.unsuppress_pane(pane_id, should_float_if_hidden);
+ drop(screen.render(None));
+ break;
+ }
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::UnsuppressOrExpandPane(pane_id, should_float_if_hidden) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.unsuppress_or_expand_pane(pane_id, should_float_if_hidden);
+ drop(screen.render(None));
+ break;
+ }
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::FocusPaneWithId(
+ pane_id,
+ should_float_if_hidden,
+ should_be_in_place_if_hidden,
+ client_id,
+ mut completion_tx,
+ ) => {
+ let pane_exists = screen
+ .tabs
+ .iter()
+ .any(|(_, tab)| tab.has_pane_with_pid(&pane_id));
+ if !pane_exists {
+ if let Some(c) = completion_tx.as_mut() {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ } else {
+ let already_focused = screen
+ .get_active_pane_id(&client_id)
+ .map(|active| active == pane_id)
+ .unwrap_or(false);
+ if already_focused {
+ if let Some(c) = completion_tx.as_mut() {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane {:?} is already focused", pane_id));
+ }
+ } else {
+ screen.focus_pane_with_id(
+ pane_id,
+ should_float_if_hidden,
+ should_be_in_place_if_hidden,
+ client_id,
+ )?;
+ screen.clear_bell_for_pane_id(pane_id, client_id);
+ screen.log_and_report_session_state()?;
+ }
+ }
+ },
+ ScreenInstruction::RenamePane(
+ pane_id,
+ new_name,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ match tab.rename_pane(new_name, pane_id) {
+ Ok(()) => drop(screen.render(None)),
+ Err(e) => log::error!("Failed to rename pane: {:?}", e),
+ }
+ break;
+ }
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::RenameActivePane(new_name, client_id, _completion_tx) => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, client_id: ClientId| tab
+ .rename_active_pane(new_name, client_id),
+ ?
+ );
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::RenameTab(
+ tab_index,
+ new_name,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ // tab_index here is 1-based user input representing display position
+ let tab_position = tab_index.saturating_sub(1); // Convert to 0-based
+
+ match screen.get_tab_by_position_mut(tab_position) {
+ Some(tab) => {
+ tab.name = String::from_utf8_lossy(&new_name).to_string();
+ },
+ None => {
+ log::error!("Failed to find tab at position: {}", tab_position);
+ },
+ }
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::GoToTabWithId(tab_id, client_id, _completion_tx) => {
+ let client_id_to_switch = client_id
+ .and_then(|cid| {
+ if screen.active_tab_ids.contains_key(&cid) {
+ Some(cid)
+ } else {
+ screen.active_tab_ids.keys().next().copied()
+ }
+ })
+ .or_else(|| screen.active_tab_ids.keys().next().copied());
+
+ if let Some(client_id) = client_id_to_switch {
+ // Get the position from the ID
+ if let Some(tab_position) = screen.get_tab_position_by_id(tab_id) {
+ // switch_active_tab expects 0-based position
+ screen.switch_active_tab(tab_position, None, true, client_id)?;
+ screen.render(None)?;
+
+ screen
+ .tab_history
+ .entry(client_id)
+ .or_insert_with(Vec::new)
+ .push(tab_id);
+ } else {
+ log::error!("Tab with ID {} not found", tab_id);
+ }
+ }
+ },
+ ScreenInstruction::RenameTabWithId(tab_id, new_name, _completion_tx) => {
+ // Use get_tab_by_id_mut() helper method
+ if let Some(tab) = screen.get_tab_by_id_mut(tab_id) {
+ tab.name = String::from_utf8_lossy(&new_name).to_string();
+ screen.log_and_report_session_state()?;
+ } else {
+ log::error!("Failed to find tab with ID: {}", tab_id);
+ }
+ },
+ ScreenInstruction::CloseTabWithId(tab_id, _completion_tx) => {
+ if screen.get_tab_by_id(tab_id).is_some() {
+ screen.close_tab_by_id(tab_id).non_fatal();
+ } else {
+ log::error!("Failed to find tab with ID: {}", tab_id);
+ }
+ },
+ ScreenInstruction::BreakPanesToTabWithId {
+ pane_ids,
+ tab_id,
+ should_change_focus_to_target_tab,
+ client_id,
+ mut completion_tx,
+ } => {
+ // Verify tab exists
+ if screen.get_tab_by_id(tab_id).is_none() {
+ log::error!("Tab with ID {} not found", tab_id);
+ // Don't set affected_tab_id, it will remain None to signal failure
+ } else {
+ // break_multiple_panes_to_tab_with_index uses tab ID
+ screen.break_multiple_panes_to_tab_with_index(
+ pane_ids,
+ tab_id,
+ should_change_focus_to_target_tab,
+ client_id,
+ )?;
+ // Set affected tab ID (tab_id is the ID here)
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_tab_id(tab_id));
+ let pane_group = screen.get_client_pane_group(&client_id);
+ if !pane_group.is_empty() {
+ let _ = screen.bus.senders.send_to_background_jobs(
+ BackgroundJob::HighlightPanesWithMessage(
+ pane_group.iter().copied().collect(),
+ "BROKEN OUT".to_owned(),
+ ),
+ );
+ }
+ screen.clear_pane_group(&client_id);
+ }
+ },
+ ScreenInstruction::RequestPluginPermissions(plugin_id, plugin_permission) => {
+ let all_tabs = screen.get_tabs_mut();
+ let found = all_tabs.values_mut().any(|tab| {
+ if tab.has_plugin(plugin_id) {
+ tab.request_plugin_permissions(plugin_id, Some(plugin_permission.clone()));
+ true
+ } else {
+ false
+ }
+ });
+
+ if !found {
+ log::error!("PluginId '{}' not found - caching request", plugin_id);
+ pending_events_waiting_for_client.push(
+ ScreenInstruction::RequestPluginPermissions(plugin_id, plugin_permission),
+ );
+ }
+ },
+ ScreenInstruction::BreakPane(
+ default_shell,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ let default_layout = screen.default_layout.clone();
+ screen.break_pane(default_shell, default_layout, client_id)?;
+ },
+ ScreenInstruction::BreakPaneRight(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.break_pane_to_new_tab(Direction::Right, client_id)?;
+ },
+ ScreenInstruction::BreakPaneLeft(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.break_pane_to_new_tab(Direction::Left, client_id)?;
+ },
+ ScreenInstruction::UpdateSessionInfos(new_session_infos, resurrectable_sessions) => {
+ screen.update_session_infos(new_session_infos, resurrectable_sessions)?;
+ },
+ ScreenInstruction::UpdateAvailableLayouts(layouts, errors) => {
+ screen.update_available_layouts(layouts, errors);
+ },
+ ScreenInstruction::ReplacePane(
+ new_pane_id,
+ hold_for_command,
+ pane_title,
+ invoked_with,
+ close_replaced_pane,
+ client_id_tab_index_or_pane_id,
+ mut completion_tx,
+ ) => {
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_pane_id(new_pane_id));
+ screen.replace_pane(
+ new_pane_id,
+ hold_for_command,
+ invoked_with,
+ pane_title,
+ close_replaced_pane,
+ client_id_tab_index_or_pane_id,
+ )?;
+
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::SerializeLayoutForResurrection => {
+ if screen.session_serialization {
+ screen.dump_layout_to_hd()?;
+ }
+ },
+ ScreenInstruction::SaveSession(_client_id, completion_tx) => {
+ let err_context = || "Failed to save session";
+
+ screen.update_active_pane_ids();
+ let pane_manifest = screen.generate_and_report_pane_state()?;
+ let tab_infos = screen.generate_and_report_tab_state()?;
+
+ #[cfg(not(test))]
+ let (available_layouts, _layout_errors) = Layout::list_available_layouts(
+ screen.layout_dir.clone(),
+ &screen.default_layout_name,
+ );
+ #[cfg(test)]
+ let available_layouts = vec![];
+
+ let creation_time = {
+ let sock_path = ZELLIJ_SOCK_DIR.join(&screen.session_name);
+ std::fs::metadata(&sock_path)
+ .ok()
+ .and_then(|f| f.created().ok().or_else(|| f.modified().ok()))
+ .and_then(|d| d.elapsed().ok())
+ .map(|d| Duration::from_secs(d.as_secs()))
+ .unwrap_or_default()
+ };
+ let session_info = SessionInfo {
+ name: screen.session_name.clone(),
+ tabs: tab_infos,
+ panes: pane_manifest,
+ connected_clients: screen.active_tab_ids.keys().len(),
+ is_current_session: true,
+ available_layouts,
+ web_clients_allowed: screen.web_sharing.web_clients_allowed(),
+ web_client_count: screen
+ .connected_clients
+ .borrow()
+ .iter()
+ .filter(|(_client_id, is_web_client)| **is_web_client)
+ .count(),
+ plugins: Default::default(),
+ tab_history: screen.tab_history.clone(),
+ pane_history: screen
+ .pane_history
+ .iter()
+ .map(|(k, v)| (*k, v.iter().map(|v| (*v).into()).collect()))
+ .collect(),
+ creation_time,
+ };
+
+ let session_layout_metadata = if screen.session_serialization {
+ screen.get_layout_metadata(Some(screen.default_shell.clone()), None)
+ } else {
+ // Create empty metadata if serialization is disabled
+ SessionLayoutMetadata::new(screen.default_layout.clone())
+ };
+
+ screen
+ .bus
+ .senders
+ .send_to_pty(PtyInstruction::SaveSessionToDisk {
+ session_name: screen.session_name.clone(),
+ session_info,
+ session_layout_metadata,
+ completion_tx,
+ })
+ .with_context(err_context)?;
+ },
+ ScreenInstruction::RenameSession(
+ name,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ if screen.peer_sessions_cache.contains_key(&name) {
+ let error_text = "A session by this name already exists.";
+ log::error!("{}", error_text);
+ if let Some(os_input) = &mut screen.bus.os_input {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::LogError {
+ lines: vec![error_text.to_owned()],
+ },
+ );
+ }
+ } else if screen.resurrectable_sessions_cache.contains_key(&name) {
+ let error_text =
+ "A resurrectable session by this name exists, cannot use this name.";
+ log::error!("{}", error_text);
+ if let Some(os_input) = &mut screen.bus.os_input {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::LogError {
+ lines: vec![error_text.to_owned()],
+ },
+ );
+ }
+ } else {
+ let err_context = || format!("Failed to rename session");
+ let old_session_name = screen.session_name.clone();
+
+ // update state
+ screen.session_name = name.clone();
+ screen.default_mode_info.session_name = Some(name.clone());
+ for (_client_id, mode_info) in screen.mode_info.iter_mut() {
+ mode_info.session_name = Some(name.clone());
+ }
+ for (_, tab) in screen.tabs.iter_mut() {
+ tab.rename_session(name.clone()).with_context(err_context)?;
+ }
+
+ // rename socket file
+ let old_socket_file_path = ZELLIJ_SOCK_DIR.join(&old_session_name);
+ let new_socket_file_path = ZELLIJ_SOCK_DIR.join(&name);
+ if let Err(e) = std::fs::rename(old_socket_file_path, new_socket_file_path) {
+ log::error!("Failed to rename ipc socket: {:?}", e);
+ }
+
+ // rename session_info folder (TODO: make this atomic, right now there is a
+ // chance background_jobs will re-create this folder before it knows the
+ // session was renamed)
+ let old_session_info_folder =
+ session_info_folder_for_session(&old_session_name);
+ let new_session_info_folder = session_info_folder_for_session(&name);
+ if let Err(e) =
+ std::fs::rename(old_session_info_folder, new_session_info_folder)
+ {
+ log::error!("Failed to rename session_info folder: {:?}", e);
+ }
+
+ // report
+ screen
+ .log_and_report_session_state()
+ .with_context(err_context)?;
+
+ // set the env variable
+ set_session_name(name.clone());
+ let connected_client_ids: Vec<ClientId> =
+ screen.active_tab_ids.keys().copied().collect();
+ for client_id in connected_client_ids {
+ if let Some(os_input) = &mut screen.bus.os_input {
+ let _ = os_input.send_to_client(
+ client_id,
+ ServerToClientMsg::RenamedSession { name: name.clone() },
+ );
+ }
+ }
+ }
+ },
+ ScreenInstruction::Reconfigure {
+ client_id,
+ keybinds,
+ default_mode,
+ theme,
+ host_theme_dark,
+ host_theme_light,
+ simplified_ui,
+ default_shell,
+ pane_frame_style,
+ copy_to_clipboard,
+ copy_command,
+ copy_on_select,
+ auto_layout,
+ rounded_corners,
+ hide_session_name,
+ stacked_resize,
+ stacked_pane_list,
+ default_editor,
+ advanced_mouse_actions,
+ mouse_scroll_resize,
+ mouse_hover_effects,
+ visual_bell,
+ focus_follows_mouse,
+ mouse_click_through,
+ nested_session_handling,
+ } => {
+ screen.host_theme_dark_styling = host_theme_dark;
+ screen.host_theme_light_styling = host_theme_light;
+ screen
+ .reconfigure(
+ keybinds,
+ default_mode,
+ theme,
+ simplified_ui,
+ default_shell,
+ pane_frame_style,
+ copy_command,
+ copy_to_clipboard,
+ copy_on_select,
+ auto_layout,
+ rounded_corners,
+ hide_session_name,
+ stacked_resize,
+ stacked_pane_list,
+ default_editor,
+ advanced_mouse_actions,
+ mouse_scroll_resize,
+ mouse_hover_effects,
+ visual_bell,
+ focus_follows_mouse,
+ mouse_click_through,
+ nested_session_handling,
+ client_id,
+ )
+ .non_fatal();
+ },
+ ScreenInstruction::RerunCommandPane(terminal_pane_id, completion_tx) => {
+ screen.rerun_command_pane_with_id(terminal_pane_id, completion_tx)
+ },
+ ScreenInstruction::ResizePaneWithId(resize, pane_id) => {
+ screen.resize_pane_with_id(resize, pane_id)
+ },
+ ScreenInstruction::EditScrollbackForPaneWithId(pane_id, completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.edit_scrollback_for_pane_with_id(pane_id, completion_tx)
+ .non_fatal();
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::WriteToPaneId(bytes, pane_id, _completion) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.write_to_pane_id(&None, bytes, false, pane_id, None, None)
+ .non_fatal();
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::Paste(bytes, pane_id, client_id, _completion) => {
+ match pane_id {
+ Some(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.paste_to_pane_id(bytes, pane_id, _completion)
+ .non_fatal();
+ break;
+ }
+ }
+ },
+ None => {
+ active_tab_and_connected_client_id!(
+ screen,
+ client_id,
+ |tab: &mut Tab, _client_id: ClientId| {
+ tab.paste_to_active_terminal(bytes, client_id, _completion)
+ .non_fatal();
+ }
+ );
+ },
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::SetPaneColor(pane_id, fg, bg, _completion) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_pane_color(pane_id, fg, bg).non_fatal();
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::WriteKeyToPaneId(
+ key_with_modifier,
+ bytes,
+ is_kitty,
+ pane_id,
+ _completion,
+ ) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.write_to_pane_id(
+ &key_with_modifier,
+ bytes,
+ is_kitty,
+ pane_id,
+ None, // client_id not needed for targeted write
+ None, // completion handled by instruction
+ )
+ .non_fatal();
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::CopyTextToClipboard(text, plugin_id) => {
+ let plugin_pane_id = PaneId::Plugin(plugin_id);
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&plugin_pane_id) {
+ tab.copy_text_to_clipboard(&text)
+ .with_context(|| {
+ format!(
+ "failed to copy text to clipboard from plugin {}",
+ plugin_id
+ )
+ })
+ .non_fatal();
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::MovePaneWithPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.move_pane(pane_id);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::MovePaneWithPaneIdInDirection(pane_id, direction) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ match direction {
+ Direction::Down => tab.move_pane_down(pane_id),
+ Direction::Up => tab.move_pane_up(pane_id),
+ Direction::Left => tab.move_pane_left(pane_id),
+ Direction::Right => tab.move_pane_right(pane_id),
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearScreenForPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_screen_for_pane_id(pane_id);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollUpInPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if let PaneId::Terminal(terminal_pane_id) = pane_id {
+ tab.scroll_terminal_up(terminal_pane_id)?;
+ } else {
+ // this is because to do this with plugins, we need the client_id -
+ // which we do not have (yet?) in this context...
+ log::error!(
+ "Currently only terminal panes are supported for scrolling up"
+ );
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollDownInPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if let PaneId::Terminal(terminal_pane_id) = pane_id {
+ tab.scroll_terminal_down(terminal_pane_id)?;
+ } else {
+ // this is because to do this with plugins, we need the client_id -
+ // which we do not have (yet?) in this context...
+ log::error!(
+ "Currently only terminal panes are supported for scrolling down"
+ );
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollToTopInPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if let PaneId::Terminal(terminal_pane_id) = pane_id {
+ tab.scroll_terminal_to_top(terminal_pane_id);
+ } else {
+ // this is because to do this with plugins, we need the client_id -
+ // which we do not have (yet?) in this context...
+ log::error!(
+ "Currently only terminal panes are supported for scrolling to top"
+ );
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollToBottomInPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if let PaneId::Terminal(terminal_pane_id) = pane_id {
+ tab.scroll_terminal_to_bottom(terminal_pane_id);
+ } else {
+ // this is because to do this with plugins, we need the client_id -
+ // which we do not have (yet?) in this context...
+ log::error!("Currently only terminal panes are supported for scrolling to bottom");
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::PageScrollUpInPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if let PaneId::Terminal(terminal_pane_id) = pane_id {
+ tab.scroll_terminal_page_up(terminal_pane_id);
+ } else {
+ // this is because to do this with plugins, we need the client_id -
+ // which we do not have (yet?) in this context...
+ log::error!(
+ "Currently only terminal panes are supported for scrolling"
+ );
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::PageScrollDownInPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if let PaneId::Terminal(terminal_pane_id) = pane_id {
+ tab.scroll_terminal_page_down(terminal_pane_id);
+ } else {
+ // this is because to do this with plugins, we need the client_id -
+ // which we do not have (yet?) in this context...
+ log::error!(
+ "Currently only terminal panes are supported for scrolling"
+ );
+ }
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::TogglePaneIdFullscreen(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_fullscreen(pane_id);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::SetTabFit {
+ client_id,
+ tab_id,
+ fit,
+ } => {
+ let changed = match fit {
+ Some((pane_id, size)) => {
+ screen.set_tab_fit(client_id, tab_id, pane_id, size)?;
+ true
+ },
+ None => screen.exit_fit_mode(client_id)?,
+ };
+ if changed {
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(pane_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_embed_or_floating_for_pane_id(pane_id, None)
+ .non_fatal();
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::CloseTabWithIndex(tab_index) => {
+ // tab_index here means display position (0-based) per API convention
+ // Must map to stable ID for close_tab_by_id()
+
+ if let Some(tab_id) = screen.get_tab_id_at_position(tab_index) {
+ screen.close_tab_by_id(tab_id).non_fatal();
+ } else {
+ log::error!("Failed to find tab at position: {}", tab_index);
+ }
+ },
+ ScreenInstruction::BreakPanesToNewTab {
+ pane_ids,
+ default_shell,
+ should_change_focus_to_new_tab,
+ new_tab_name,
+ client_id,
+ mut completion_tx,
+ } => {
+ let tab_id = screen.break_multiple_panes_to_new_tab(
+ pane_ids,
+ default_shell,
+ should_change_focus_to_new_tab,
+ new_tab_name,
+ client_id,
+ )?;
+ // Set affected tab ID for plugin API return value
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_tab_id(tab_id));
+ // TODO: is this a race?
+ let pane_group = screen.get_client_pane_group(&client_id);
+ if !pane_group.is_empty() {
+ let _ = screen.bus.senders.send_to_background_jobs(
+ BackgroundJob::HighlightPanesWithMessage(
+ pane_group.iter().copied().collect(),
+ "BROKEN OUT".to_owned(),
+ ),
+ );
+ }
+ screen.clear_pane_group(&client_id);
+ },
+ ScreenInstruction::BreakPanesToTabWithIndex {
+ pane_ids,
+ tab_index,
+ should_change_focus_to_new_tab,
+ client_id,
+ mut completion_tx,
+ } => {
+ // tab_index is the target tab ID
+ screen.break_multiple_panes_to_tab_with_index(
+ pane_ids,
+ tab_index,
+ should_change_focus_to_new_tab,
+ client_id,
+ )?;
+ // Set affected tab ID (tab_index is the ID here)
+ completion_tx
+ .as_mut()
+ .map(|c| c.set_affected_tab_id(tab_index));
+ let pane_group = screen.get_client_pane_group(&client_id);
+ if !pane_group.is_empty() {
+ let _ = screen.bus.senders.send_to_background_jobs(
+ BackgroundJob::HighlightPanesWithMessage(
+ pane_group.iter().copied().collect(),
+ "BROKEN OUT".to_owned(),
+ ),
+ );
+ }
+ screen.clear_pane_group(&client_id);
+ },
+ ScreenInstruction::TogglePanePinned(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.toggle_pane_pinned(client_id);
+ },
+ ScreenInstruction::SetFloatingPanePinned(pane_id, should_be_pinned) => {
+ screen.set_floating_pane_pinned(pane_id, should_be_pinned);
+ },
+ ScreenInstruction::StackPanes(
+ pane_ids_to_stack,
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ if let Some(last_pane_id) = screen.stack_panes(pane_ids_to_stack) {
+ let _ = screen.focus_pane_with_id(last_pane_id, false, false, client_id);
+ let _ = screen.render(None);
+ let pane_group = screen.get_client_pane_group(&client_id);
+ if !pane_group.is_empty() {
+ let _ = screen.bus.senders.send_to_background_jobs(
+ BackgroundJob::HighlightPanesWithMessage(
+ pane_group.iter().copied().collect(),
+ "STACKED".to_owned(),
+ ),
+ );
+ }
+ screen.clear_pane_group(&client_id);
+ }
+ },
+ ScreenInstruction::ChangeFloatingPanesCoordinates(
+ pane_ids_and_coordinates,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.change_floating_panes_coordinates(pane_ids_and_coordinates);
+ let _ = screen.render(None);
+ },
+ ScreenInstruction::TogglePaneBorderless(pane_id, _completion_tx) => {
+ screen.toggle_pane_borderless(pane_id);
+ let _ = screen.render(None);
+ },
+ ScreenInstruction::SetPaneBorderless(pane_id, borderless, _completion_tx) => {
+ screen.set_pane_borderless(pane_id, borderless);
+ let _ = screen.render(None);
+ },
+ ScreenInstruction::GroupAndUngroupPanes(
+ pane_ids_to_group,
+ pane_ids_to_ungroup,
+ for_all_clients,
+ client_id,
+ ) => {
+ screen.group_and_ungroup_panes(
+ pane_ids_to_group,
+ pane_ids_to_ungroup,
+ for_all_clients,
+ client_id,
+ );
+ let _ = screen.log_and_report_session_state();
+ },
+ ScreenInstruction::TogglePaneInGroup(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.toggle_pane_in_group(client_id).non_fatal();
+ },
+ ScreenInstruction::ToggleGroupMarking(
+ client_id,
+ _completion_tx, // the action ends here, dropping this will release anything
+ // waiting for it
+ ) => {
+ screen.toggle_group_marking(client_id).non_fatal();
+ },
+ ScreenInstruction::SessionSharingStatusChange(web_sharing) => {
+ if web_sharing {
+ screen.web_sharing = WebSharing::On;
+ } else {
+ screen.web_sharing = WebSharing::Off;
+ }
+
+ for tab in screen.tabs.values_mut() {
+ tab.update_web_sharing(screen.web_sharing);
+ }
+ let _ = screen.log_and_report_session_state();
+ let _ = screen.render(None);
+ },
+ ScreenInstruction::HighlightAndUnhighlightPanes(
+ pane_ids_to_highlight,
+ pane_ids_to_unhighlight,
+ client_id,
+ ) => {
+ {
+ let all_tabs = screen.get_tabs_mut();
+ for pane_id in pane_ids_to_highlight {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.add_highlight_pane_frame_color_override(
+ pane_id,
+ None,
+ Some(client_id),
+ );
+ }
+ }
+ }
+ for pane_id in pane_ids_to_unhighlight {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_pane_frame_color_override(pane_id, Some(client_id));
+ }
+ }
+ }
+ screen.render(None)?;
+ }
+ let _ = screen.log_and_report_session_state();
+ },
+ ScreenInstruction::FloatMultiplePanes(pane_ids_to_float, client_id) => {
+ {
+ let all_tabs = screen.get_tabs_mut();
+ let mut ejected_panes_in_group = vec![];
+ for pane_id in pane_ids_to_float {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if !tab.pane_id_is_floating(&pane_id) {
+ ejected_panes_in_group.push(pane_id);
+ tab.toggle_pane_embed_or_floating_for_pane_id(
+ pane_id,
+ Some(client_id),
+ )
+ .non_fatal();
+ }
+ tab.show_floating_panes();
+ }
+ }
+ }
+ screen.render(None)?;
+ if !ejected_panes_in_group.is_empty() {
+ let _ = screen.bus.senders.send_to_background_jobs(
+ BackgroundJob::HighlightPanesWithMessage(
+ ejected_panes_in_group,
+ "EJECTED".to_owned(),
+ ),
+ );
+ }
+ }
+ let _ = screen.log_and_report_session_state();
+ },
+ ScreenInstruction::EmbedMultiplePanes(pane_ids_to_float, client_id) => {
+ {
+ let all_tabs = screen.get_tabs_mut();
+ let mut embedded_panes_in_group = vec![];
+ for pane_id in pane_ids_to_float {
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if tab.pane_id_is_floating(&pane_id) {
+ embedded_panes_in_group.push(pane_id);
+ tab.toggle_pane_embed_or_floating_for_pane_id(
+ pane_id,
+ Some(client_id),
+ )
+ .non_fatal();
+ }
+ tab.hide_floating_panes();
+ }
+ }
+ }
+ screen.render(None)?;
+ if !embedded_panes_in_group.is_empty() {
+ let _ = screen.bus.senders.send_to_background_jobs(
+ BackgroundJob::HighlightPanesWithMessage(
+ embedded_panes_in_group,
+ "EMBEDDED".to_owned(),
+ ),
+ );
+ }
+ }
+ let _ = screen.log_and_report_session_state();
+ },
+ ScreenInstruction::InterceptKeyPresses(plugin_id, client_id) => {
+ keybind_intercepts.insert(client_id, plugin_id);
+ },
+ ScreenInstruction::ClearKeyPressesIntercepts(client_id) => {
+ keybind_intercepts.remove(&client_id);
+ },
+ ScreenInstruction::ReplacePaneWithExistingPane(
+ old_pane_id,
+ new_pane_id,
+ suppress_replaced_pane,
+ completion_tx,
+ ) => screen.replace_pane_with_existing_pane(
+ old_pane_id,
+ new_pane_id,
+ suppress_replaced_pane,
+ completion_tx,
+ ),
+ ScreenInstruction::AddWatcherClient(client_id, size) => {
+ screen
+ .add_watcher_client(client_id)
+ .context("failed to add watcher client")?;
+ screen.set_watcher_size(client_id, size);
+ screen.render(None)?;
+ },
+ ScreenInstruction::RemoveWatcherClient(client_id) => {
+ screen.remove_watcher_client(client_id);
+ },
+ ScreenInstruction::SetFollowedClient(client_id) => {
+ screen
+ .set_followed_client(client_id)
+ .context("failed to set followed client")?;
+ },
+ ScreenInstruction::WatcherTerminalResize(client_id, size) => {
+ screen.set_watcher_size(client_id, size);
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearMouseHelpText(client_id) => {
+ if let Ok(tab) = screen.get_active_tab_mut(client_id) {
+ tab.clear_mouse_help_text(client_id);
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id,
+ highlights,
+ } => {
+ let style = screen.style.clone();
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_plugin_regex_highlights_for_pane(
+ pane_id, plugin_id, highlights, &style,
+ );
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_plugin_highlights_for_pane(pane_id, plugin_id);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearAllPluginHighlights(plugin_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ tab.clear_all_plugin_highlights(plugin_id);
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::DesktopNotificationResponse(raw_bytes, client_id) => {
+ if let Some((terminal_id, app_wants_report, is_query, rewritten_bytes)) =
+ denormalize_notification_response(&raw_bytes)
+ {
+ let pane_id = PaneId::Terminal(terminal_id);
+ // Write response to the pane if the app expects it:
+ // capability query answers (q flag) or activation reports (r flag)
+ if app_wants_report || is_query {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.write_to_pane_id(
+ &None,
+ rewritten_bytes,
+ false,
+ pane_id,
+ None,
+ None,
+ )
+ .non_fatal();
+ break;
+ }
+ }
+ }
+ // Focus the pane on activation click (not on query responses)
+ if !is_query {
+ screen
+ .focus_pane_with_id(pane_id, false, false, client_id)
+ .non_fatal();
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ }
+ },
+ ScreenInstruction::SubscribeToPaneRenders {
+ client_id,
+ pane_ids,
+ scrollback,
+ ansi,
+ } => {
+ screen.subscribe_to_pane_renders(client_id, pane_ids, scrollback, ansi);
+ },
+ ScreenInstruction::NotifyPaneClosedToSubscribers { pane_id } => {
+ screen.notify_pane_closed_to_subscribers(pane_id);
+ screen.clear_fit_for_closed_pane(pane_id.into())?;
+ },
+ ScreenInstruction::PluginSubscribedToAnsiPaneContents(has_subscribers) => {
+ let previously_subscribed = screen.plugins_need_ansi_pane_contents;
+ screen.plugins_need_ansi_pane_contents = has_subscribers;
+ if has_subscribers && !previously_subscribed {
+ if let Err(e) = screen.render(None) {
+ log::error!("Failed to render after ANSI subscription enabled: {:?}", e);
+ }
+ }
+ },
+ ScreenInstruction::UpdateBackgroundPluginSubscriptions(
+ plugin_id,
+ client_id,
+ subscriptions,
+ ) => {
+ if subscriptions.is_empty() {
+ screen
+ .background_plugin_subscriptions
+ .remove(&(plugin_id, client_id));
+ } else {
+ screen
+ .background_plugin_subscriptions
+ .insert((plugin_id, client_id), subscriptions);
+ }
+ },
+ ScreenInstruction::ClearHintTextCache => {
+ for tab in screen.tabs.values_mut() {
+ tab.clear_hint_text_cache();
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::BroadcastModeUpdate(mode_info, target_client_id) => {
+ screen.broadcast_mode_update(mode_info, target_client_id)?;
+ },
+ // Pane-targeting CLI handlers
+ ScreenInstruction::ScrollUpWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.scroll_up_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollDownWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.scroll_down_by_pane_id(pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollToTopWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.scroll_to_top_by_pane_id(pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ScrollToBottomWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.scroll_to_bottom_by_pane_id(pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::PageScrollUpWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.page_scroll_up_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::PageScrollDownWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.page_scroll_down_by_pane_id(pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::HalfPageScrollUpWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.half_page_scroll_up_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::HalfPageScrollDownWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.half_page_scroll_down_by_pane_id(pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ResizeWithPaneId(pane_id, strategy, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.resize_by_pane_id(pane_id, strategy);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneWithPaneIdCli(pane_id, direction, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.move_pane_by_pane_id(pane_id, direction);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MovePaneBackwardsWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.move_pane_backwards_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::ClearScreenWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_screen_by_pane_id(pane_id).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::EditScrollbackWithPaneId(pane_id, ansi, completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ if ansi {
+ tab.edit_scrollback_raw_for_pane_with_id(pane_id, completion_tx)
+ .non_fatal();
+ } else {
+ tab.edit_scrollback_for_pane_with_id(pane_id, completion_tx)
+ .non_fatal();
+ }
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleFullscreenWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_fullscreen_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::ToggleNoUiFullscreenWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_no_ui_fullscreen_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_embed_or_floating_for_pane_id(pane_id, None)
+ .non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::CloseFocusWithPaneId(pane_id, completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.close_pane_by_pane_id(pane_id, completion_tx)
+ .non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::RenamePaneWithPaneId(pane_id, name, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.rename_pane_by_pane_id(pane_id, name).non_fatal();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::UndoRenamePaneWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.undo_rename_pane_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::TogglePanePinnedWithPaneId(pane_id, mut _completion_tx) => {
+ let all_tabs = screen.get_tabs_mut();
+ let mut found = false;
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_pinned_by_pane_id(pane_id);
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ log::error!("Pane with id {:?} not found", pane_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Pane with id {:?} not found", pane_id));
+ }
+ }
+ },
+ // Tab-targeting CLI handlers
+ ScreenInstruction::UndoRenameTabWithTabId(tab_id, mut _completion_tx) => {
+ if let Some(tab) = screen.tabs.get_mut(&tab_id) {
+ if tab.name != tab.prev_name {
+ tab.name = tab.prev_name.clone();
+ screen.log_and_report_session_state()?;
+ }
+ } else {
+ log::error!("Tab with id {} not found", tab_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Tab with id {} not found", tab_id));
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleActiveSyncTabWithTabId(tab_id, mut _completion_tx) => {
+ if let Some(tab) = screen.tabs.get_mut(&tab_id) {
+ tab.toggle_sync_panes_is_active();
+ } else {
+ log::error!("Tab with id {} not found", tab_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Tab with id {} not found", tab_id));
+ }
+ }
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleFloatingPanesWithTabId(
+ tab_id,
+ default_shell,
+ mut completion_tx,
+ ) => {
+ if let Some(tab) = screen.tabs.get_mut(&tab_id) {
+ // Pass None as client_id so that if a new floating pane must be spawned,
+ // it targets this tab (via TabIndex) rather than the focused tab of some client.
+ tab.toggle_floating_panes(None, default_shell, completion_tx)
+ .non_fatal();
+ } else {
+ log::error!("Tab with id {} not found", tab_id);
+ if let Some(ref mut c) = completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Tab with id {} not found", tab_id));
+ }
+ drop(completion_tx);
+ }
+ screen.log_and_report_session_state()?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::PreviousSwapLayoutWithTabId(tab_id, mut _completion_tx) => {
+ if let Some(tab) = screen.tabs.get_mut(&tab_id) {
+ tab.previous_swap_layout().non_fatal();
+ } else {
+ log::error!("Tab with id {} not found", tab_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Tab with id {} not found", tab_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::NextSwapLayoutWithTabId(tab_id, mut _completion_tx) => {
+ if let Some(tab) = screen.tabs.get_mut(&tab_id) {
+ tab.next_swap_layout().non_fatal();
+ } else {
+ log::error!("Tab with id {} not found", tab_id);
+ if let Some(ref mut c) = _completion_tx {
+ c.set_exit_status(1);
+ c.set_error_message(format!("Tab with id {} not found", tab_id));
+ }
+ }
+ screen.render(None)?;
+ screen.log_and_report_session_state()?;
+ },
+ ScreenInstruction::MoveTabWithTabId(tab_id, direction, _completion_tx) => {
+ if pending_tab_ids.is_empty() {
+ screen.move_tab_by_id(tab_id, direction)?;
+ screen.render(None)?;
+ } else {
+ pending_events_waiting_for_tab.push(ScreenInstruction::MoveTabWithTabId(
+ tab_id,
+ direction,
+ _completion_tx,
+ ));
+ }
+ },
+ ScreenInstruction::EnterMobileMode(client_id, _completion_tx) => {
+ if let Err(e) = screen.enter_mobile_mode(client_id) {
+ screen.mobile_render_gate.ungate(client_id);
+ screen.render(None)?;
+ return Err(e);
+ }
+ screen.mobile_state.mark_auto_entered(client_id);
+ screen.render(None)?;
+ },
+ ScreenInstruction::ExitMobileMode(client_id, _completion_tx) => {
+ screen.exit_mobile_mode(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleMobileMode(client_id, _completion_tx) => {
+ screen.toggle_mobile_mode(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ReevaluateMobileMode {
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ } => {
+ screen.reevaluate_mobile_mode(
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ )?;
+ },
+ ScreenInstruction::SetSoftKeyboard { client_id, on } => {
+ if let Some(os_input) = &screen.bus.os_input {
+ let _ = os_input
+ .send_to_client(client_id, ServerToClientMsg::SetSoftKeyboard { on });
+ }
+ },
+ ScreenInstruction::SetShadowFocus(client_id, pane_id) => {
+ screen.set_shadow_focus(client_id, pane_id)?;
+ },
+ ScreenInstruction::FocusHostSession(client_id, _completion_tx) => {
+ let payload = nested_session::encode_payload(&NestedSessionMessage::FocusHost {
+ direction: None,
+ });
+ let _ = screen.bus.senders.send_to_server(
+ ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload),
+ );
+ },
+ ScreenInstruction::FocusGuestSession(client_id, _completion_tx) => {
+ screen.focus_guest_session(client_id);
+ },
+ ScreenInstruction::ToggleHostFullscreen(client_id, _completion_tx) => {
+ screen.own_fullscreen_requested = !screen.own_fullscreen_requested;
+ let fullscreen = screen.own_fullscreen_requested;
+ let payload =
+ nested_session::encode_payload(&NestedSessionMessage::ToggleHostFullscreen {
+ fullscreen,
+ });
+ let _ = screen.bus.senders.send_to_server(
+ ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload),
+ );
+ },
+ }
+ }
+ Ok(())
+}
+
+#[path = "./unit/screen_tests.rs"]
+#[cfg(test)]
+mod screen_tests;