aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-07-27 18:24:37 +0200
committerGitHub <noreply@github.com>2026-07-27 18:24:37 +0200
commitea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d (patch)
tree4a8fac8a9b27f2a8b32b3af61a3b5419b9937c81
parentSwitch from the unmaintained daemonize crate to a maintained fork (#4512) (diff)
downloadsl0ppij-ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d.tar.gz
sl0ppij-ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d.tar.xz
sl0ppij-ea07e2d5b6bbc9ea6f9c765b0838b3e91156a58d.zip
Nested session handling (#5417)
* fix: theme query and updated pixel dimensions * add no-ui-fullscreen key/action/capability * protocol core * integration test harness * fix integration tests * fix flaky tests * nested ui (dimming) and directional focus initial implementation * integration tests for nested ui dimming and directional focus * initial full screen nesteed implementation * fix: keep hidden floating panes hidden across nested no-ui fullscreen * fix integration tests * no ui fullscreen integration tests * hardening behavior/tests * fix(nested): re-derive descend/dim on host tab switch and new-tab so clicking dimmed chrome ascends * initial modal implementation * initial ui improvements * fix integration tests * improve nested selection ui * nested session modal refactoring and tests * improve integration test speed somewhat * some ui adjustments * simplify full/auto nested session handling * hints in compact-bar * guest stdin leak fix * allow floating panes to be fullscreen * make nested session handling configurable * protocol reannounce * refactor: naming confusion * improve ux somewhat * rustfmt * merge fixes * improve integration test rate limiting (fix ci) * add pr
-rw-r--r--.config/nextest.toml4
-rw-r--r--CHANGELOG.md1
-rw-r--r--Cargo.lock1
-rw-r--r--default-plugins/about/src/main.rs1
-rw-r--r--default-plugins/compact-bar/src/line.rs322
-rw-r--r--default-plugins/compact-bar/src/main.rs20
-rw-r--r--default-plugins/compact-bar/src/tab.rs28
-rw-r--r--default-plugins/configuration/src/presets.rs24
-rw-r--r--default-plugins/status-bar/src/first_line.rs4
-rw-r--r--default-plugins/status-bar/src/main.rs50
-rw-r--r--default-plugins/status-bar/src/one_line_ui.rs131
-rw-r--r--default-plugins/status-bar/src/second_line.rs54
-rw-r--r--default-plugins/tab-bar/src/line.rs191
-rw-r--r--default-plugins/tab-bar/src/main.rs20
-rw-r--r--default-plugins/tab-bar/src/tab.rs30
-rw-r--r--example/alt-centered-config.kdl1
-rw-r--r--example/config.kdl1
-rw-r--r--example/default.kdl1
-rw-r--r--xtask/src/build.rs163
-rw-r--r--xtask/src/integration_test.rs2
-rw-r--r--zellij-client/src/input_handler.rs40
-rw-r--r--zellij-client/src/lib.rs149
-rw-r--r--zellij-client/src/nested_reannounce.rs63
-rw-r--r--zellij-client/src/stdin_ansi_parser.rs31
-rw-r--r--zellij-client/src/stdin_ansi_parser_tests.rs104
-rw-r--r--zellij-client/src/stdin_handler.rs34
-rw-r--r--zellij-client/src/unit/terminal_loop_tests.rs169
-rw-r--r--zellij-client/src/web_client/control_message.rs1
-rw-r--r--zellij-client/src/web_client/server_listener.rs1
-rw-r--r--zellij-client/src/web_client/websocket_handlers.rs3
-rw-r--r--zellij-integration-tests/src/client_screen.rs125
-rw-r--r--zellij-integration-tests/src/fake_client_os_api.rs22
-rw-r--r--zellij-integration-tests/src/fake_pty.rs60
-rw-r--r--zellij-integration-tests/src/lib.rs12
-rw-r--r--zellij-integration-tests/src/nested.rs697
-rw-r--r--zellij-integration-tests/src/runner.rs127
-rw-r--r--zellij-integration-tests/tests/about_pane.rs59
-rw-r--r--zellij-integration-tests/tests/app.rs28
-rw-r--r--zellij-integration-tests/tests/nested_fullscreen.rs1147
-rw-r--r--zellij-integration-tests/tests/nested_guest_modal.rs280
-rw-r--r--zellij-integration-tests/tests/nested_hardening.rs523
-rw-r--r--zellij-integration-tests/tests/nested_session_handling_config.rs127
-rw-r--r--zellij-integration-tests/tests/nested_sessions.rs1492
-rw-r--r--zellij-integration-tests/tests/nested_status_hints.rs28
-rw-r--r--zellij-integration-tests/tests/no_ui_fullscreen.rs413
-rw-r--r--zellij-integration-tests/tests/panes.rs75
-rw-r--r--zellij-integration-tests/tests/search.rs3
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap6
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap8
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap9
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap16
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap26
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap25
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap7
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap9
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap25
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap11
-rw-r--r--zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap11
-rw-r--r--zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap6
-rw-r--r--zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap6
-rw-r--r--zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap6
-rw-r--r--zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap6
-rw-r--r--zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap28
-rw-r--r--zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap4
-rw-r--r--zellij-integration-tests/tests/startup_host_query.rs16
-rw-r--r--zellij-server/src/background_jobs.rs32
-rw-r--r--zellij-server/src/lib.rs125
-rw-r--r--zellij-server/src/nested_guest.rs205
-rw-r--r--zellij-server/src/panes/floating_panes/mod.rs361
-rw-r--r--zellij-server/src/panes/grid.rs19
-rw-r--r--zellij-server/src/panes/mod.rs1
-rw-r--r--zellij-server/src/panes/nested_session_modal.rs531
-rw-r--r--zellij-server/src/panes/terminal_pane.rs180
-rw-r--r--zellij-server/src/panes/tiled_panes/mod.rs322
-rw-r--r--zellij-server/src/panes/unit/grid_tests.rs182
-rw-r--r--zellij-server/src/panes/unit/nested_session_modal_tests.rs360
-rw-r--r--zellij-server/src/panes/unit/terminal_pane_tests.rs147
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs22
-rw-r--r--zellij-server/src/route.rs116
-rw-r--r--zellij-server/src/screen.rs1462
-rw-r--r--zellij-server/src/tab/mod.rs457
-rw-r--r--zellij-server/src/tab/mouse_handler.rs62
-rw-r--r--zellij-server/src/tab/unit/layout_applier_tests.rs8
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap25
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap25
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap25
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap25
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap25
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap25
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap25
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs222
-rw-r--r--zellij-server/src/tab/unit/tab_tests.rs816
-rw-r--r--zellij-server/src/ui/components/mod.rs32
-rw-r--r--zellij-server/src/ui/components/nested_list.rs1
-rw-r--r--zellij-server/src/ui/components/ribbon.rs40
-rw-r--r--zellij-server/src/ui/components/text.rs42
-rw-r--r--zellij-server/src/ui/pane_boundaries_frame.rs21
-rw-r--r--zellij-server/src/ui/pane_contents_and_ui.rs65
-rw-r--r--zellij-server/src/unit/screen_tests.rs410
-rw-r--r--zellij-tile/src/shim.rs14
-rw-r--r--zellij-tile/src/ui_components/text.rs57
-rw-r--r--zellij-utils/Cargo.toml1
-rw-r--r--zellij-utils/assets/config/default.kdl13
-rwxr-xr-xzellij-utils/assets/plugins/about.wasmbin1514076 -> 1521285 bytes
-rwxr-xr-xzellij-utils/assets/plugins/compact-bar.wasmbin1706212 -> 1724730 bytes
-rwxr-xr-xzellij-utils/assets/plugins/configuration.wasmbin1570529 -> 1581477 bytes
-rwxr-xr-xzellij-utils/assets/plugins/fixture-plugin-for-tests.wasmbin1667362 -> 1678053 bytes
-rwxr-xr-xzellij-utils/assets/plugins/layout-manager.wasmbin1854571 -> 1863316 bytes
-rwxr-xr-xzellij-utils/assets/plugins/link.wasmbin1291116 -> 1299490 bytes
-rwxr-xr-xzellij-utils/assets/plugins/mobile.wasmbin1584658 -> 1593212 bytes
-rwxr-xr-xzellij-utils/assets/plugins/multiple-select.wasmbin1510648 -> 1519783 bytes
-rwxr-xr-xzellij-utils/assets/plugins/plugin-manager.wasmbin1470625 -> 1479337 bytes
-rwxr-xr-xzellij-utils/assets/plugins/session-manager.wasmbin1692291 -> 1700725 bytes
-rwxr-xr-xzellij-utils/assets/plugins/share.wasmbin1467284 -> 1476405 bytes
-rwxr-xr-xzellij-utils/assets/plugins/status-bar.wasmbin1657492 -> 1676078 bytes
-rwxr-xr-xzellij-utils/assets/plugins/strider.wasmbin1668563 -> 1679481 bytes
-rwxr-xr-xzellij-utils/assets/plugins/tab-bar.wasmbin1727875 -> 1745115 bytes
-rw-r--r--zellij-utils/assets/prost/api.action.rs12
-rw-r--r--zellij-utils/assets/prost/api.event.rs12
-rw-r--r--zellij-utils/assets/prost/api.plugin_command.rs6
-rw-r--r--zellij-utils/assets/prost_ipc/client_server_contract.rs93
-rw-r--r--zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs3
-rw-r--r--zellij-utils/assets/prost_nested_session/nested_session_contract.rs170
-rw-r--r--zellij-utils/src/cli.rs12
-rw-r--r--zellij-utils/src/client_server_contract/client_to_server.proto5
-rw-r--r--zellij-utils/src/client_server_contract/common_types.proto21
-rw-r--r--zellij-utils/src/client_server_contract/server_to_client.proto5
-rw-r--r--zellij-utils/src/data.rs9
-rw-r--r--zellij-utils/src/errors.rs14
-rw-r--r--zellij-utils/src/input/actions.rs44
-rw-r--r--zellij-utils/src/input/keybinds.rs54
-rw-r--r--zellij-utils/src/input/mod.rs6
-rw-r--r--zellij-utils/src/input/options.rs45
-rw-r--r--zellij-utils/src/ipc.rs6
-rw-r--r--zellij-utils/src/ipc/protobuf_conversion.rs115
-rw-r--r--zellij-utils/src/ipc/tests/roundtrip_tests.rs82
-rw-r--r--zellij-utils/src/kdl/mod.rs107
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap4
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap13
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap9
-rw-r--r--zellij-utils/src/lib.rs3
-rw-r--r--zellij-utils/src/nested_session.rs470
-rw-r--r--zellij-utils/src/nested_session_contract/mod.rs4
-rw-r--r--zellij-utils/src/nested_session_contract/nested_session_message.proto80
-rw-r--r--zellij-utils/src/plugin_api/action.proto4
-rw-r--r--zellij-utils/src/plugin_api/action.rs37
-rw-r--r--zellij-utils/src/plugin_api/event.proto6
-rw-r--r--zellij-utils/src/plugin_api/event.rs58
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.proto2
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.rs20
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap47
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap47
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap1
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap47
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap47
169 files changed, 15113 insertions, 479 deletions
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 7c915027..b8ca3337 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -1,3 +1,7 @@
[profile.default]
slow-timeout = { period = "30s", terminate-after = 2 }
fail-fast = false
+
+[[profile.default.overrides]]
+filter = 'binary(nested_fullscreen) + binary(nested_sessions) + binary(nested_hardening) + binary(nested_guest_modal) + binary(nested_status_hints) + binary(startup_host_query)'
+threads-required = 4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4572300a..7def5c1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* fix: occasional input spam when pressing ESC and holding the mouse over slow SSH connections (https://github.com/zellij-org/zellij/pull/5323)
* chore: switch cassowary deps to maintained fork kasuari (https://github.com/zellij-org/zellij/pull/5416)
* chore: switch unmaintained daemonize with maintained fork daemonix (https://github.com/zellij-org/zellij/pull/4512)
+* feat: handle nested Zellij sessions (https://github.com/zellij-org/zellij/pull/5417)
## [0.44.3] - 2026-05-13
* fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139)
diff --git a/Cargo.lock b/Cargo.lock
index 27958cad..fbe5d3b2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5130,6 +5130,7 @@ version = "0.45.0"
dependencies = [
"anyhow",
"backtrace",
+ "base64 0.13.0",
"bitflags 2.11.0",
"clap",
"clap_complete",
diff --git a/default-plugins/about/src/main.rs b/default-plugins/about/src/main.rs
index 42f87055..c0d1f398 100644
--- a/default-plugins/about/src/main.rs
+++ b/default-plugins/about/src/main.rs
@@ -311,7 +311,6 @@ impl App {
)
.unwrap(),
)]);
- let _ = show_floating_panes(None);
}
}
}
diff --git a/default-plugins/compact-bar/src/line.rs b/default-plugins/compact-bar/src/line.rs
index 089b6ed3..aae8aec0 100644
--- a/default-plugins/compact-bar/src/line.rs
+++ b/default-plugins/compact-bar/src/line.rs
@@ -5,13 +5,25 @@ use crate::{LinePart, TabRenderData, ARROW_SEPARATOR};
use zellij_tile::prelude::*;
use zellij_tile_utils::style;
+pub struct TabLineOutput {
+ pub parts: Vec<LinePart>,
+ pub breadcrumb_range: Option<(usize, usize)>,
+}
+
pub fn tab_line(
mode_info: &ModeInfo,
tab_data: TabRenderData,
cols: usize,
toggle_tooltip_key: Option<String>,
tooltip_is_active: bool,
-) -> Vec<LinePart> {
+) -> TabLineOutput {
+ let dimmed = mode_info.session_ascended == Some(true) || mode_info.session_dimmed == Some(true);
+ let breadcrumb_ancestry = if mode_info.host_fullscreen == Some(true) {
+ mode_info.session_ancestry.clone()
+ } else {
+ Vec::new()
+ };
+ let nested_hint = NestedSessionHint::from_mode_info(mode_info);
let config = TabLineConfig {
session_name: mode_info.session_name.to_owned(),
hide_session_name: mode_info.style.hide_session_name,
@@ -20,6 +32,9 @@ pub fn tab_line(
is_swap_layout_dirty: tab_data.is_swap_layout_dirty,
toggle_tooltip_key,
tooltip_is_active,
+ dimmed,
+ breadcrumb_ancestry,
+ nested_hint,
};
let builder = TabLineBuilder::new(config, mode_info.style.colors, mode_info.capabilities, cols);
@@ -27,6 +42,29 @@ pub fn tab_line(
}
#[derive(Debug, Clone)]
+pub enum NestedSessionHint {
+ None,
+ Ascend(Vec<KeyWithModifier>),
+ Descend(Vec<KeyWithModifier>),
+}
+
+impl NestedSessionHint {
+ fn from_mode_info(mode_info: &ModeInfo) -> Self {
+ if mode_info.session_dimmed == Some(true) {
+ NestedSessionHint::Ascend(mode_info.nested_ascend_keys.clone())
+ } else if mode_info.session_ascended == Some(true) {
+ NestedSessionHint::Descend(mode_info.nested_descend_keys.clone())
+ } else {
+ NestedSessionHint::None
+ }
+ }
+
+ fn is_active(&self) -> bool {
+ !matches!(self, NestedSessionHint::None)
+ }
+}
+
+#[derive(Debug, Clone)]
pub struct TabLineConfig {
pub session_name: Option<String>,
pub hide_session_name: bool,
@@ -35,24 +73,38 @@ pub struct TabLineConfig {
pub is_swap_layout_dirty: bool,
pub toggle_tooltip_key: Option<String>,
pub tooltip_is_active: bool,
+ pub dimmed: bool,
+ pub breadcrumb_ancestry: Vec<String>,
+ pub nested_hint: NestedSessionHint,
}
fn calculate_total_length(parts: &[LinePart]) -> usize {
parts.iter().map(|p| p.len).sum()
}
+fn dim_style(bg: PaletteColor) -> ansi_term::Style {
+ ansi_term::Style::new()
+ .on(match bg {
+ PaletteColor::Rgb((r, g, b)) => ansi_term::Color::RGB(r, g, b),
+ PaletteColor::EightBit(c) => ansi_term::Color::Fixed(c),
+ })
+ .dimmed()
+}
+
struct TabLinePopulator {
cols: usize,
palette: Styling,
capabilities: PluginCapabilities,
+ dimmed: bool,
}
impl TabLinePopulator {
- fn new(cols: usize, palette: Styling, capabilities: PluginCapabilities) -> Self {
+ fn new(cols: usize, palette: Styling, capabilities: PluginCapabilities, dimmed: bool) -> Self {
Self {
cols,
palette,
capabilities,
+ dimmed,
}
}
@@ -218,9 +270,15 @@ impl TabLinePopulator {
background: self.palette.text_selected.emphasis_0,
};
+ let text_style = if self.dimmed {
+ style!(colors.text, colors.background).italic()
+ } else {
+ style!(colors.text, colors.background).bold()
+ };
+
let styled_parts = [
style!(colors.separator, colors.background).paint(separator),
- style!(colors.text, colors.background).bold().paint(text),
+ text_style.paint(text),
style!(colors.background, colors.separator).paint(separator),
];
@@ -264,22 +322,43 @@ enum TabAction {
Finish,
}
-struct TabLinePrefixBuilder {
+struct TabLinePrefixBuilder<'a> {
palette: Styling,
cols: usize,
+ dimmed: bool,
+ breadcrumb_ancestry: &'a [String],
+}
+
+struct TabLinePrefix {
+ parts: Vec<LinePart>,
+ breadcrumb_range: Option<(usize, usize)>,
}
-impl TabLinePrefixBuilder {
- fn new(palette: Styling, cols: usize) -> Self {
- Self { palette, cols }
+impl<'a> TabLinePrefixBuilder<'a> {
+ fn new(palette: Styling, cols: usize, dimmed: bool, breadcrumb_ancestry: &'a [String]) -> Self {
+ Self {
+ palette,
+ cols,
+ dimmed,
+ breadcrumb_ancestry,
+ }
}
- fn build(&self, session_name: Option<&str>, mode: InputMode) -> Vec<LinePart> {
+ fn build(&self, session_name: Option<&str>, mode: InputMode) -> TabLinePrefix {
let mut parts = vec![self.create_zellij_part()];
let mut used_len = parts.get(0).map_or(0, |p| p.len);
+ let mut breadcrumb_range = None;
if let Some(name) = session_name {
- if let Some(name_part) = self.create_session_name_part(name, used_len) {
+ if !self.breadcrumb_ancestry.is_empty() {
+ if let Some((mut breadcrumb_parts, range, breadcrumb_len)) =
+ self.create_breadcrumb_parts(name, used_len)
+ {
+ used_len += breadcrumb_len;
+ breadcrumb_range = Some(range);
+ parts.append(&mut breadcrumb_parts);
+ }
+ } else if let Some(name_part) = self.create_session_name_part(name, used_len) {
used_len += name_part.len;
parts.push(name_part);
}
@@ -289,18 +368,81 @@ impl TabLinePrefixBuilder {
parts.push(mode_part);
}
- parts
+ TabLinePrefix {
+ parts,
+ breadcrumb_range,
+ }
+ }
+
+ fn create_breadcrumb_parts(
+ &self,
+ name: &str,
+ used_len: usize,
+ ) -> Option<(Vec<LinePart>, (usize, usize), usize)> {
+ let bg_color = self.palette.text_unselected.background;
+ let text_color = self.palette.text_unselected.base;
+ let ancestor_text = format!("({} ▸ ", self.breadcrumb_ancestry.join(" ▸ "));
+ let ancestor_len = ancestor_text.width();
+ let name_text = name.to_string();
+ let name_len = name_text.width();
+ let closing_text = ") ".to_string();
+ let closing_len = closing_text.width();
+ let total_len = ancestor_len + name_len + closing_len;
+
+ if self.cols.saturating_sub(used_len) < total_len {
+ return None;
+ }
+
+ let ancestor_style = if self.dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(text_color, bg_color).italic()
+ };
+ let name_style = if self.dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(self.palette.text_unselected.emphasis_0, bg_color).bold()
+ };
+ let closing_style = if self.dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(text_color, bg_color).bold()
+ };
+
+ let ancestor_start = used_len;
+ let ancestor_end = ancestor_start + ancestor_len;
+ let parts = vec![
+ LinePart {
+ part: ancestor_style.paint(ancestor_text).to_string(),
+ len: ancestor_len,
+ tab_index: None,
+ },
+ LinePart {
+ part: name_style.paint(name_text).to_string(),
+ len: name_len,
+ tab_index: None,
+ },
+ LinePart {
+ part: closing_style.paint(closing_text).to_string(),
+ len: closing_len,
+ tab_index: None,
+ },
+ ];
+
+ Some((parts, (ancestor_start, ancestor_end), total_len))
}
fn create_zellij_part(&self) -> LinePart {
let prefix_text = " Zellij ";
let colors = self.get_text_colors();
+ let text_style = if self.dimmed {
+ style!(colors.text, colors.background).italic()
+ } else {
+ style!(colors.text, colors.background).bold()
+ };
LinePart {
- part: style!(colors.text, colors.background)
- .bold()
- .paint(prefix_text)
- .to_string(),
+ part: text_style.paint(prefix_text).to_string(),
len: prefix_text.chars().count(),
tab_index: None,
}
@@ -312,11 +454,13 @@ impl TabLinePrefixBuilder {
if self.cols.saturating_sub(used_len) >= name_part_len {
let colors = self.get_text_colors();
+ let text_style = if self.dimmed {
+ style!(colors.text, colors.background).italic()
+ } else {
+ style!(colors.text, colors.background).bold()
+ };
Some(LinePart {
- part: style!(colors.text, colors.background)
- .bold()
- .paint(name_part)
- .to_string(),
+ part: text_style.paint(name_part).to_string(),
len: name_part_len,
tab_index: None,
})
@@ -331,18 +475,23 @@ impl TabLinePrefixBuilder {
if self.cols.saturating_sub(used_len) >= mode_len {
let colors = self.get_text_colors();
- let style = match mode {
- InputMode::Locked => {
- style!(self.palette.text_unselected.emphasis_3, colors.background)
- },
- InputMode::Normal => {
- style!(self.palette.text_unselected.emphasis_2, colors.background)
- },
- _ => style!(self.palette.text_unselected.emphasis_0, colors.background),
+ let style = if self.dimmed {
+ style!(colors.text, colors.background).italic()
+ } else {
+ match mode {
+ InputMode::Locked => {
+ style!(self.palette.text_unselected.emphasis_3, colors.background)
+ },
+ InputMode::Normal => {
+ style!(self.palette.text_unselected.emphasis_2, colors.background)
+ },
+ _ => style!(self.palette.text_unselected.emphasis_0, colors.background),
+ }
+ .bold()
};
Some(LinePart {
- part: style.bold().paint(mode_text).to_string(),
+ part: style.paint(mode_text).to_string(),
len: mode_len,
tab_index: None,
})
@@ -363,17 +512,26 @@ impl TabLinePrefixBuilder {
struct RightSideElementsBuilder {
palette: Styling,
capabilities: PluginCapabilities,
+ dimmed: bool,
}
impl RightSideElementsBuilder {
- fn new(palette: Styling, capabilities: PluginCapabilities) -> Self {
+ fn new(palette: Styling, capabilities: PluginCapabilities, dimmed: bool) -> Self {
Self {
palette,
capabilities,
+ dimmed,
}
}
fn build(&self, config: &TabLineConfig, available_space: usize) -> Vec<LinePart> {
+ if config.nested_hint.is_active() {
+ if let Some(hint) = self.create_nested_hint(&config.nested_hint, available_space) {
+ return vec![hint];
+ }
+ return Vec::new();
+ }
+
let mut elements = Vec::new();
if let Some(ref tooltip_key) = config.toggle_tooltip_key {
@@ -387,13 +545,77 @@ impl RightSideElementsBuilder {
elements
}
+ fn create_nested_hint(&self, hint: &NestedSessionHint, max_len: usize) -> Option<LinePart> {
+ let (prefix, keys) = match hint {
+ NestedSessionHint::Ascend(keys) => ("Ascend: ", keys),
+ NestedSessionHint::Descend(keys) => ("Descend: ", keys),
+ NestedSessionHint::None => return None,
+ };
+
+ let bg = self.palette.text_unselected.background;
+ let key_color = self.palette.text_unselected.emphasis_0;
+ let sep_color = self.palette.text_unselected.base;
+ let keys_text: Vec<String> = if keys.is_empty() {
+ vec!["<unbound>".to_string()]
+ } else {
+ keys.iter().map(|key| format!("<{}>", key)).collect()
+ };
+
+ let prefix_len = prefix.width() + 1;
+ let keys_display_len: usize = keys_text.iter().map(|k| k.width()).sum::<usize>()
+ + keys_text.len().saturating_sub(1) * 3;
+
+ if !keys_text.is_empty() && prefix_len + keys_display_len <= max_len {
+ let prefix_style = dim_style(bg).italic();
+ let mut part = prefix_style.paint(format!(" {}", prefix)).to_string();
+ for (i, key) in keys_text.iter().enumerate() {
+ if i > 0 {
+ part.push_str(&style!(sep_color, bg).paint(" + ").to_string());
+ }
+ if keys.is_empty() {
+ part.push_str(&dim_style(bg).italic().paint(key).to_string());
+ } else {
+ part.push_str(&style!(key_color, bg).bold().paint(key).to_string());
+ }
+ }
+ return Some(LinePart {
+ part,
+ len: prefix_len + keys_display_len,
+ tab_index: None,
+ });
+ }
+
+ let message = match hint {
+ NestedSessionHint::Ascend(_) => " nested session",
+ NestedSessionHint::Descend(_) => " host session",
+ NestedSessionHint::None => return None,
+ };
+ let message_len = message.width();
+ if message_len <= max_len {
+ let style = dim_style(bg).italic();
+ return Some(LinePart {
+ part: style.paint(message).to_string(),
+ len: message_len,
+ tab_index: None,
+ });
+ }
+
+ None
+ }
+
fn create_tooltip_indicator(&self, toggle_key: &str, is_active: bool) -> LinePart {
let key_text = toggle_key;
- let key = Text::new(key_text).color_all(3).opaque();
+ let key = if self.dimmed {
+ Text::new(key_text).disabled().opaque()
+ } else {
+ Text::new(key_text).color_all(3).opaque()
+ };
let ribbon_text = "Tooltip";
let mut ribbon = Text::new(ribbon_text);
- if is_active {
+ if self.dimmed {
+ ribbon = ribbon.disabled();
+ } else if is_active {
ribbon = ribbon.selected();
}
@@ -459,6 +681,14 @@ impl RightSideElementsBuilder {
colors: &SwapLayoutColors,
separator: &str,
) -> (String, String, String) {
+ if self.dimmed {
+ let text_style = style!(colors.bg, colors.fg).italic();
+ return (
+ style!(colors.bg, colors.fg).paint(separator).to_string(),
+ text_style.paint(layout_name).to_string(),
+ style!(colors.fg, colors.bg).paint(separator).to_string(),
+ );
+ }
match mode {
InputMode::Locked => (
style!(colors.bg, colors.fg).paint(separator).to_string(),
@@ -517,22 +747,37 @@ impl TabLineBuilder {
}
}
- pub fn build(self, all_tabs: Vec<LinePart>, active_tab_index: usize) -> Vec<LinePart> {
+ pub fn build(self, all_tabs: Vec<LinePart>, active_tab_index: usize) -> TabLineOutput {
let (tabs_before_active, active_tab, tabs_after_active) =
self.split_tabs(all_tabs, active_tab_index);
- let prefix_builder = TabLinePrefixBuilder::new(self.palette, self.cols);
let session_name = if self.config.hide_session_name {
None
} else {
self.config.session_name.as_deref()
};
+ let breadcrumb_ancestry: &[String] = if self.config.hide_session_name {
+ &[]
+ } else {
+ &self.config.breadcrumb_ancestry
+ };
+ let prefix_builder = TabLinePrefixBuilder::new(
+ self.palette,
+ self.cols,
+ self.config.dimmed,
+ breadcrumb_ancestry,
+ );
- let mut prefix = prefix_builder.build(session_name, self.config.mode);
+ let prefix_result = prefix_builder.build(session_name, self.config.mode);
+ let mut prefix = prefix_result.parts;
+ let breadcrumb_range = prefix_result.breadcrumb_range;
let prefix_len = calculate_total_length(&prefix);
if prefix_len + active_tab.len > self.cols {
- return prefix;
+ return TabLineOutput {
+ parts: prefix,
+ breadcrumb_range,
+ };
}
let mut tabs_to_render = vec![active_tab];
@@ -540,6 +785,7 @@ impl TabLineBuilder {
self.cols.saturating_sub(prefix_len),
self.palette,
self.capabilities,
+ self.config.dimmed,
);
let mut tabs_before = tabs_before_active;
@@ -549,7 +795,10 @@ impl TabLineBuilder {
prefix.append(&mut tabs_to_render);
self.add_right_side_elements(&mut prefix);
- prefix
+ TabLineOutput {
+ parts: prefix,
+ breadcrumb_range,
+ }
}
fn split_tabs(
@@ -573,7 +822,8 @@ impl TabLineBuilder {
let current_len = calculate_total_length(prefix);
if current_len < self.cols {
- let right_builder = RightSideElementsBuilder::new(self.palette, self.capabilities);
+ let right_builder =
+ RightSideElementsBuilder::new(self.palette, self.capabilities, self.config.dimmed);
let available_space = self.cols.saturating_sub(current_len);
let mut right_elements = right_builder.build(&self.config, available_space);
diff --git a/default-plugins/compact-bar/src/main.rs b/default-plugins/compact-bar/src/main.rs
index 3a8446a5..29975d9a 100644
--- a/default-plugins/compact-bar/src/main.rs
+++ b/default-plugins/compact-bar/src/main.rs
@@ -41,6 +41,7 @@ struct State {
// Display state
mode_info: ModeInfo,
tab_line: Vec<LinePart>,
+ breadcrumb_range: Option<(usize, usize)>,
display_area_rows: usize,
display_area_cols: usize,
@@ -278,13 +279,23 @@ impl State {
}
match mouse_event {
- Mouse::LeftClick(_, col) => self.handle_tab_click(col),
+ Mouse::LeftClick(_, col) => {
+ if self.click_is_on_breadcrumb(col) {
+ focus_host_session();
+ } else {
+ self.handle_tab_click(col);
+ }
+ },
Mouse::ScrollUp(_) => self.scroll_tab_up(),
Mouse::ScrollDown(_) => self.scroll_tab_down(),
_ => {},
}
}
+ fn click_is_on_breadcrumb(&self, col: usize) -> bool {
+ matches!(self.breadcrumb_range, Some((start, end)) if col >= start && col < end)
+ }
+
fn handle_clipboard_copy(&mut self, copy_destination: CopyDestination) -> bool {
if self.is_tooltip {
return false;
@@ -514,13 +525,15 @@ impl State {
}
let tab_data = self.prepare_tab_data();
- self.tab_line = tab_line(
+ let tab_line_output = tab_line(
&self.mode_info,
tab_data,
cols,
self.toggle_tooltip_key.clone(),
self.tooltip_is_active,
);
+ self.tab_line = tab_line_output.parts;
+ self.breadcrumb_range = tab_line_output.breadcrumb_range;
let output = self
.tab_line
@@ -536,6 +549,8 @@ impl State {
let mut active_swap_layout_name = None;
let mut is_swap_layout_dirty = false;
let mut is_alternate_tab = false;
+ let dimmed = self.mode_info.session_ascended == Some(true)
+ || self.mode_info.session_dimmed == Some(true);
for tab in &self.tabs {
let tab_name = self.get_tab_display_name(tab);
@@ -554,6 +569,7 @@ impl State {
is_alternate_tab,
self.mode_info.style.colors,
self.mode_info.capabilities,
+ dimmed,
);
is_alternate_tab = !is_alternate_tab;
diff --git a/default-plugins/compact-bar/src/tab.rs b/default-plugins/compact-bar/src/tab.rs
index 8d4ada96..ac1602c3 100644
--- a/default-plugins/compact-bar/src/tab.rs
+++ b/default-plugins/compact-bar/src/tab.rs
@@ -27,6 +27,7 @@ pub fn render_tab(
is_alternate_tab: bool,
palette: Styling,
separator: &str,
+ dimmed: bool,
) -> LinePart {
let focused_clients = tab.other_focused_clients.as_slice();
let separator_width = separator.width();
@@ -54,12 +55,20 @@ pub fn render_tab(
palette.ribbon_unselected.base
};
let separator_fill_color = palette.text_unselected.background;
+ let background_color = if dimmed {
+ palette.ribbon_unselected.background
+ } else {
+ background_color
+ };
+ let text_style = if dimmed {
+ style!(palette.ribbon_unselected.base, background_color).italic()
+ } else {
+ style!(foreground_color, background_color).bold()
+ };
let left_separator = style!(separator_fill_color, background_color).paint(separator);
let mut tab_text_len = text.width() + (separator_width * 2) + 2; // + 2 for padding
- let tab_styled_text = style!(foreground_color, background_color)
- .bold()
- .paint(format!(" {} ", text));
+ let tab_styled_text = text_style.paint(format!(" {} ", text));
let right_separator = style!(background_color, separator_fill_color).paint(separator);
let tab_styled_text = if !focused_clients.is_empty() {
@@ -67,15 +76,9 @@ pub fn render_tab(
cursors(focused_clients, palette.multiplayer_user_colors);
tab_text_len += extra_length;
let mut s = String::new();
- let cursor_beginning = style!(foreground_color, background_color)
- .bold()
- .paint("[")
- .to_string();
+ let cursor_beginning = text_style.paint("[").to_string();
let cursor_section = ANSIStrings(&cursor_section).to_string();
- let cursor_end = style!(foreground_color, background_color)
- .bold()
- .paint("]")
- .to_string();
+ let cursor_end = text_style.paint("]").to_string();
s.push_str(&left_separator.to_string());
s.push_str(&tab_styled_text.to_string());
s.push_str(&cursor_beginning);
@@ -100,6 +103,7 @@ pub fn tab_style(
mut is_alternate_tab: bool,
palette: Styling,
capabilities: PluginCapabilities,
+ dimmed: bool,
) -> LinePart {
let separator = tab_separator(capabilities);
@@ -116,7 +120,7 @@ pub fn tab_style(
is_alternate_tab = false;
}
- render_tab(tabname, tab, is_alternate_tab, palette, separator)
+ render_tab(tabname, tab, is_alternate_tab, palette, separator, dimmed)
}
pub(crate) fn get_tab_to_focus(
diff --git a/default-plugins/configuration/src/presets.rs b/default-plugins/configuration/src/presets.rs
index 4eb65042..a577baf2 100644
--- a/default-plugins/configuration/src/presets.rs
+++ b/default-plugins/configuration/src/presets.rs
@@ -34,6 +34,7 @@ keybinds clear-defaults=true {{
bind "s" {{ NewPane "stacked"; SwitchToMode "Locked"; }}
bind "x" {{ CloseFocus; SwitchToMode "Locked"; }}
bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Locked"; }}
+ bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Locked"; }}
bind "z" {{ TogglePaneFrames; SwitchToMode "Locked"; }}
bind "w" {{ ToggleFloatingPanes; SwitchToMode "Locked"; }}
bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Locked"; }}
@@ -120,6 +121,9 @@ keybinds clear-defaults=true {{
session {{
bind "o" {{ SwitchToMode "Normal"; }}
bind "d" {{ Detach; }}
+ bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }}
+ bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }}
+ bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }}
bind "w" {{
LaunchOrFocusPlugin "session-manager" {{
floating true
@@ -255,6 +259,7 @@ keybinds clear-defaults=true {{
bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }}
bind "x" {{ CloseFocus; SwitchToMode "Normal"; }}
bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }}
+ bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }}
bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }}
bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }}
bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }}
@@ -343,6 +348,9 @@ keybinds clear-defaults=true {{
bind "{primary_modifier} o" {{ SwitchToMode "Normal"; }}
bind "{primary_modifier} s" {{ SwitchToMode "Scroll"; }}
bind "d" {{ Detach; }}
+ bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }}
+ bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }}
+ bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }}
bind "w" {{
LaunchOrFocusPlugin "session-manager" {{
floating true
@@ -487,6 +495,7 @@ keybinds clear-defaults=true {{
bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }}
bind "x" {{ CloseFocus; SwitchToMode "Normal"; }}
bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }}
+ bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }}
bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }}
bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }}
bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }}
@@ -566,6 +575,9 @@ keybinds clear-defaults=true {{
}}
session {{
bind "d" {{ Detach; }}
+ bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }}
+ bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }}
+ bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }}
bind "w" {{
LaunchOrFocusPlugin "session-manager" {{
floating true
@@ -690,6 +702,7 @@ keybinds clear-defaults=true {{
bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }}
bind "x" {{ CloseFocus; SwitchToMode "Normal"; }}
bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }}
+ bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }}
bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }}
bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }}
bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }}
@@ -770,6 +783,9 @@ keybinds clear-defaults=true {{
bind "{primary_modifier} o" {{ SwitchToMode "Normal"; }}
bind "{primary_modifier} s" {{ SwitchToMode "Scroll"; }}
bind "d" {{ Detach; }}
+ bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }}
+ bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }}
+ bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }}
bind "w" {{
LaunchOrFocusPlugin "session-manager" {{
floating true
@@ -900,6 +916,7 @@ keybinds clear-defaults=true {{
bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }}
bind "x" {{ CloseFocus; SwitchToMode "Normal"; }}
bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }}
+ bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }}
bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }}
bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }}
bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }}
@@ -974,6 +991,9 @@ keybinds clear-defaults=true {{
}}
session {{
bind "d" {{ Detach; }}
+ bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }}
+ bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }}
+ bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }}
bind "w" {{
LaunchOrFocusPlugin "session-manager" {{
floating true
@@ -1085,6 +1105,7 @@ keybinds clear-defaults=true {{
bind "s" {{ NewPane "stacked"; SwitchToMode "Normal"; }}
bind "x" {{ CloseFocus; SwitchToMode "Normal"; }}
bind "f" {{ ToggleFocusFullscreen; SwitchToMode "Normal"; }}
+ bind "Shift f" {{ ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }}
bind "z" {{ TogglePaneFrames; SwitchToMode "Normal"; }}
bind "w" {{ ToggleFloatingPanes; SwitchToMode "Normal"; }}
bind "e" {{ TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }}
@@ -1173,6 +1194,9 @@ keybinds clear-defaults=true {{
bind "{primary_modifier} o" {{ SwitchToMode "Normal"; }}
bind "{primary_modifier} s" {{ SwitchToMode "Scroll"; }}
bind "d" {{ Detach; }}
+ bind "]" {{ FocusHostSession; SwitchToMode "Normal"; }}
+ bind "[" {{ FocusGuestSession; SwitchToMode "Normal"; }}
+ bind "f" {{ ToggleHostFullscreen; SwitchToMode "Normal"; }}
bind "w" {{
LaunchOrFocusPlugin "session-manager" {{
floating true
diff --git a/default-plugins/status-bar/src/first_line.rs b/default-plugins/status-bar/src/first_line.rs
index b5c688ad..c2f5b477 100644
--- a/default-plugins/status-bar/src/first_line.rs
+++ b/default-plugins/status-bar/src/first_line.rs
@@ -613,7 +613,7 @@ pub fn first_line(
separator: &str,
) -> LinePart {
let supports_arrow_fonts = !help.capabilities.arrow_fonts;
- let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts);
+ let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts, false);
let binds = &help.get_mode_keybinds();
// Unselect all by default
let mut default_keys = vec![
@@ -755,7 +755,7 @@ mod tests {
fn colored_elements() -> ColoredElements {
let palette = Styling::default();
- color_elements(palette, false)
+ color_elements(palette, false, false)
}
// Strip style information from `LinePart` and return a raw String instead
diff --git a/default-plugins/status-bar/src/main.rs b/default-plugins/status-bar/src/main.rs
index 673d7c8c..f12da1cc 100644
--- a/default-plugins/status-bar/src/main.rs
+++ b/default-plugins/status-bar/src/main.rs
@@ -18,9 +18,9 @@ use zellij_tile_utils::{palette_match, style};
use first_line::first_line;
use one_line_ui::one_line_ui;
use second_line::{
- floating_panes_are_visible, fullscreen_panes_to_hide, keybinds,
- locked_floating_panes_are_visible, locked_fullscreen_panes_to_hide, system_clipboard_error,
- text_copied_hint,
+ ascended_to_host_session_hint, descended_into_nested_session_hint, floating_panes_are_visible,
+ fullscreen_panes_to_hide, keybinds, locked_floating_panes_are_visible,
+ locked_fullscreen_panes_to_hide, system_clipboard_error, text_copied_hint,
};
use tip::utils::get_cached_tip_name;
@@ -94,7 +94,33 @@ pub struct SegmentStyle {
// we need different colors from palette for the default theme
// plus here we can add new sources in the future, like Theme
// that can be defined in the config perhaps
-fn color_elements(palette: Styling, different_color_alternates: bool) -> ColoredElements {
+fn color_elements(
+ palette: Styling,
+ different_color_alternates: bool,
+ dimmed: bool,
+) -> ColoredElements {
+ if dimmed {
+ let background = palette.text_unselected.background;
+ let foreground = palette.text_unselected.base;
+ let ribbon_background = palette.ribbon_unselected.background;
+ let italic_on_ribbon = style!(palette.ribbon_unselected.base, ribbon_background).italic();
+ let dim_segment = SegmentStyle {
+ prefix_separator: style!(background, ribbon_background),
+ char_left_separator: italic_on_ribbon,
+ char_shortcut: italic_on_ribbon,
+ char_right_separator: italic_on_ribbon,
+ styled_text: italic_on_ribbon,
+ suffix_separator: style!(ribbon_background, background),
+ };
+ return ColoredElements {
+ selected: dim_segment,
+ unselected: dim_segment,
+ unselected_alternate: dim_segment,
+ disabled: dim_segment,
+ superkey_prefix: style!(foreground, background).italic(),
+ superkey_suffix_separator: style!(background, background),
+ };
+ }
let background = palette.text_unselected.background;
let foreground = palette.text_unselected.base;
let alternate_background_color = if different_color_alternates {
@@ -344,6 +370,8 @@ impl ZellijPlugin for State {
let active_tab = self.tabs.iter().find(|t| t.active);
let first_line = first_line(&self.mode_info, active_tab, cols, separator);
let second_line = self.second_line(cols);
+ let show_nested_session_hint = self.mode_info.session_dimmed.unwrap_or(false)
+ || self.mode_info.session_ascended.unwrap_or(false);
// [48;5;238m is white background, [0K is so that it fills the rest of the line
// [m is background reset, [0K is so that it clears the rest of the line
@@ -352,7 +380,9 @@ impl ZellijPlugin for State {
if rows > 1 {
println!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", first_line, r, g, b);
} else {
- if self.mode_info.mode == InputMode::Normal {
+ if show_nested_session_hint {
+ print!("\u{1b}[m{}\u{1b}[0K", second_line);
+ } else if self.mode_info.mode == InputMode::Normal {
print!("{}\u{1b}[48;2;{};{};{}m\u{1b}[0K", first_line, r, g, b);
} else {
print!("\u{1b}[m{}\u{1b}[0K", second_line);
@@ -363,7 +393,9 @@ impl ZellijPlugin for State {
if rows > 1 {
println!("{}\u{1b}[48;5;{}m\u{1b}[0K", first_line, color);
} else {
- if self.mode_info.mode == InputMode::Normal {
+ if show_nested_session_hint {
+ print!("\u{1b}[m{}\u{1b}[0K", second_line);
+ } else if self.mode_info.mode == InputMode::Normal {
print!("{}\u{1b}[48;5;{}m\u{1b}[0K", first_line, color);
} else {
print!("\u{1b}[m{}\u{1b}[0K", second_line);
@@ -382,6 +414,12 @@ impl State {
fn second_line(&self, cols: usize) -> LinePart {
let active_tab = self.tabs.iter().find(|t| t.active);
+ if self.mode_info.session_dimmed.unwrap_or(false) {
+ return descended_into_nested_session_hint(&self.mode_info, cols);
+ }
+ if self.mode_info.session_ascended.unwrap_or(false) {
+ return ascended_to_host_session_hint(&self.mode_info, cols);
+ }
if let Some(copy_destination) = self.text_copy_destination {
text_copied_hint(copy_destination)
} else if self.display_system_clipboard_failure {
diff --git a/default-plugins/status-bar/src/one_line_ui.rs b/default-plugins/status-bar/src/one_line_ui.rs
index ae57a223..62992af6 100644
--- a/default-plugins/status-bar/src/one_line_ui.rs
+++ b/default-plugins/status-bar/src/one_line_ui.rs
@@ -9,7 +9,10 @@ use zellij_tile::prelude::*;
use zellij_tile_utils::{palette_match, style};
use crate::first_line::{to_char, KeyAction, KeyMode, KeyShortcut};
-use crate::second_line::{system_clipboard_error, text_copied_hint};
+use crate::second_line::{
+ ascended_to_host_session_hint, descended_into_nested_session_hint, system_clipboard_error,
+ text_copied_hint,
+};
use crate::{action_key, action_key_group, color_elements, MORE_MSG, TO_NORMAL};
use crate::{ColoredElements, LinePart};
use unicode_width::UnicodeWidthStr;
@@ -25,6 +28,16 @@ pub fn one_line_ui(
new_pane_hovered: bool,
floating_hovered: bool,
) -> (LinePart, Option<(usize, usize)>, Option<(usize, usize)>) {
+ if help.session_dimmed.unwrap_or(false) {
+ return (
+ descended_into_nested_session_hint(help, max_len),
+ None,
+ None,
+ );
+ }
+ if help.session_ascended.unwrap_or(false) {
+ return (ascended_to_host_session_hint(help, max_len), None, None);
+ }
if let Some(text_copied_to_clipboard_destination) = text_copied_to_clipboard_destination {
return (
text_copied_hint(text_copied_to_clipboard_destination),
@@ -528,7 +541,11 @@ fn render_mode_key_indicators(
) -> Option<LinePart> {
let mut line_part_to_render = LinePart::default();
let supports_arrow_fonts = !help.capabilities.arrow_fonts;
- let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts);
+ let colored_elements = color_elements(
+ help.style.colors,
+ !supports_arrow_fonts,
+ help.session_dimmed.unwrap_or(false),
+ );
let default_keys = if base_mode_is_locked {
base_mode_locked_mode_indicators(help)
} else {
@@ -742,13 +759,14 @@ fn render_common_modifiers(
)
};
+ let prefix = if mode_info.session_dimmed.unwrap_or(false) {
+ serialize_text(&Text::new(&prefix_text).disabled().opaque())
+ } else {
+ serialize_text(&Text::new(&prefix_text).opaque())
+ };
let suffix_separator = palette.superkey_suffix_separator.paint(separator);
- line_part_to_render.part = format!(
- "{}{}{}",
- line_part_to_render.part,
- serialize_text(&Text::new(&prefix_text).opaque()),
- suffix_separator
- );
+ line_part_to_render.part =
+ format!("{}{}{}", line_part_to_render.part, prefix, suffix_separator);
line_part_to_render.len += prefix_text.chars().count() + separator.chars().count();
}
@@ -761,7 +779,11 @@ fn render_secondary_info(
) -> Option<(LinePart, Option<(usize, usize)>, Option<(usize, usize)>)> {
let mut secondary_info = LinePart::default();
let supports_arrow_fonts = !help.capabilities.arrow_fonts;
- let colored_elements = color_elements(help.style.colors, !supports_arrow_fonts);
+ let colored_elements = color_elements(
+ help.style.colors,
+ !supports_arrow_fonts,
+ help.session_dimmed.unwrap_or(false),
+ );
let (secondary_keybinds, new_pane_range, floating_range) =
secondary_keybinds(&help, tab_info, max_len, new_pane_hovered, floating_hovered);
secondary_info.append(&secondary_keybinds);
@@ -1011,6 +1033,7 @@ fn secondary_keybinds(
.join("-")
),
0,
+ help.session_dimmed.unwrap_or(false),
);
secondary_info.append(&modifier_str);
let new_pane_key_to_display: Vec<KeyWithModifier> = new_pane_key_to_display
@@ -1145,6 +1168,7 @@ fn secondary_keybinds(
.join("-")
),
0,
+ help.session_dimmed.unwrap_or(false),
);
short_line.append(&modifier_str);
let new_pane_key_to_display: Vec<KeyWithModifier> = new_pane_key_to_display
@@ -1213,11 +1237,12 @@ fn secondary_keybinds(
if short_line.len <= max_len {
(short_line, new_pane_range, floating_range)
} else if max_len >= 3 {
- let part = serialize_text(
- &Text::new(format!("{:>width$}", "...", width = max_len))
- .color_range(0, ..)
- .opaque(),
- );
+ let overflow_text = Text::new(format!("{:>width$}", "...", width = max_len));
+ let part = if help.session_dimmed.unwrap_or(false) {
+ serialize_text(&overflow_text.disabled().opaque())
+ } else {
+ serialize_text(&overflow_text.color_range(0, ..).opaque())
+ };
let len = max_len;
(LinePart { part, len }, None, None)
} else {
@@ -1232,8 +1257,12 @@ fn secondary_keybinds(
}
}
-fn text_as_line_part_with_emphasis(text: String, emphases_index: usize) -> LinePart {
- let part = serialize_text(&Text::new(&text).color_range(emphases_index, ..).opaque());
+fn text_as_line_part_with_emphasis(text: String, emphases_index: usize, dimmed: bool) -> LinePart {
+ let part = if dimmed {
+ serialize_text(&Text::new(&text).disabled().opaque())
+ } else {
+ serialize_text(&Text::new(&text).color_range(emphases_index, ..).opaque())
+ };
LinePart {
part,
len: text.width(),
@@ -1279,11 +1308,14 @@ fn add_shortcut_hovered(
keys: &Vec<KeyWithModifier>,
key_color_index: Option<usize>,
) -> LinePart {
+ if help.session_dimmed.unwrap_or(false) {
+ return add_shortcut(help, text, keys, false, key_color_index);
+ }
let mut ret = LinePart::default();
if keys.is_empty() {
return ret;
}
- ret.append(&style_key_with_modifier(&keys, key_color_index));
+ ret.append(&style_key_with_modifier(&keys, key_color_index, false));
let palette = help.style.colors;
let supports_arrow_fonts = !help.capabilities.arrow_fonts;
let ribbon_bg = palette.ribbon_unselected.emphasis_1;
@@ -1306,6 +1338,9 @@ fn add_shortcut_with_inline_key_hovered(
text: &str,
key: Vec<KeyWithModifier>,
) -> LinePart {
+ if help.session_dimmed.unwrap_or(false) {
+ return add_shortcut_with_inline_key(help, text, key, false);
+ }
let mut ret = LinePart::default();
if key.is_empty() {
return ret;
@@ -1360,14 +1395,17 @@ fn add_shortcut(
selected: bool,
key_color_index: Option<usize>,
) -> LinePart {
+ let dimmed = help.session_dimmed.unwrap_or(false);
let mut ret = LinePart::default();
if keys.is_empty() {
return ret;
}
- ret.append(&style_key_with_modifier(&keys, key_color_index)); // TODO: alternate
- //
- let ribbon = if selected {
+ ret.append(&style_key_with_modifier(&keys, key_color_index, dimmed)); // TODO: alternate
+ //
+ let ribbon = if dimmed {
+ serialize_ribbon(&Text::new(format!("{}", text)).disabled())
+ } else if selected {
serialize_ribbon(&Text::new(format!("{}", text)).selected())
} else {
serialize_ribbon(&Text::new(format!("{}", text)))
@@ -1420,7 +1458,9 @@ fn add_shortcut_with_inline_key(
.join(key_separator)
);
- let ribbon = if is_selected {
+ let ribbon = if help.session_dimmed.unwrap_or(false) {
+ serialize_ribbon(&Text::new(format!("<{}> {}", key_string, text)).disabled())
+ } else if is_selected {
serialize_ribbon(
&Text::new(format!("<{}> {}", key_string, text))
.color_range(0, 1..key_string.width() + 1)
@@ -1461,7 +1501,9 @@ fn add_shortcut_with_key_only(
.join("-")
);
- let ribbon = if is_selected {
+ let ribbon = if help.session_dimmed.unwrap_or(false) {
+ serialize_ribbon(&Text::new(format!("{}", key_string)).disabled())
+ } else if is_selected {
serialize_ribbon(
&Text::new(format!("{}", key_string))
.color_range(0, ..)
@@ -1489,6 +1531,37 @@ fn add_keygroup_separator(help: &ModeInfo, max_len: usize) -> Option<LinePart> {
};
let palette = help.style.colors;
+ if help.session_dimmed.unwrap_or(false) {
+ let mut ret = LinePart::default();
+ let dim_style = Style::new()
+ .fg(palette_match!(palette.text_unselected.base))
+ .on(palette_match!(palette.text_unselected.background))
+ .italic();
+ let mut bits: Vec<ANSIString> = vec![];
+ let mode_help_text = match help.mode {
+ InputMode::RenamePane => Some("RENAMING PANE"),
+ InputMode::RenameTab => Some("RENAMING TAB"),
+ InputMode::EnterSearch => Some("ENTERING SEARCH TERM"),
+ InputMode::Search => Some("SEARCHING"),
+ _ => None,
+ };
+ if let Some(mode_help_text) = mode_help_text {
+ bits.push(dim_style.paint(format!(" {} ", mode_help_text)));
+ ret.len += mode_help_text.width() + 2;
+ }
+ bits.push(dim_style.paint(format!("{}", separator)));
+ bits.push(dim_style.paint(format!(" ")));
+ bits.push(dim_style.paint(format!("{}", separator)));
+ ret.part = format!("{}{}", ret.part, ANSIStrings(&bits));
+ ret.len += 3;
+
+ if ret.len <= max_len {
+ return Some(ret);
+ } else {
+ return None;
+ }
+ }
+
let mut ret = LinePart::default();
let separator_color = palette_match!(palette.text_unselected.emphasis_0);
@@ -1916,7 +1989,11 @@ fn configuration_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithMo
}
}
-fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize>) -> LinePart {
+fn style_key_with_modifier(
+ keyvec: &[KeyWithModifier],
+ color_index: Option<usize>,
+ dimmed: bool,
+) -> LinePart {
if keyvec.is_empty() {
return LinePart::default();
}
@@ -1956,7 +2033,9 @@ fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize
if no_common_modifier || key.len() == 1 {
let key_string_text = format!(" {} ", key.join(key_separator));
- let text = if let Some(color_index) = color_index {
+ let text = if dimmed {
+ Text::new(&key_string_text).disabled().opaque()
+ } else if let Some(color_index) = color_index {
Text::new(&key_string_text)
.color_range(color_index, ..)
.opaque()
@@ -1970,7 +2049,9 @@ fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize
} else {
let key_string_without_modifier = format!("{}", key.join(key_separator));
let key_string_text = format!(" {} <{}> ", modifier_str, key_string_without_modifier);
- let text = if let Some(color_index) = color_index {
+ let text = if dimmed {
+ Text::new(&key_string_text).disabled().opaque()
+ } else if let Some(color_index) = color_index {
Text::new(&key_string_text)
.color_range(color_index, ..modifier_str.width() + 1)
.color_range(
diff --git a/default-plugins/status-bar/src/second_line.rs b/default-plugins/status-bar/src/second_line.rs
index 921f3c75..63f845a6 100644
--- a/default-plugins/status-bar/src/second_line.rs
+++ b/default-plugins/status-bar/src/second_line.rs
@@ -368,6 +368,60 @@ pub fn keybinds(help: &ModeInfo, tip_name: &str, max_width: usize) -> LinePart {
best_effort_shortcut_list(help, tip_body.short, max_width)
}
+pub fn descended_into_nested_session_hint(help: &ModeInfo, max_len: usize) -> LinePart {
+ nested_session_status_hint(help, "Ascend: ", &help.nested_ascend_keys, max_len)
+}
+
+pub fn ascended_to_host_session_hint(help: &ModeInfo, max_len: usize) -> LinePart {
+ nested_session_status_hint(help, "Descend: ", &help.nested_descend_keys, max_len)
+}
+
+fn nested_session_status_hint(
+ help: &ModeInfo,
+ label: &str,
+ keys: &[KeyWithModifier],
+ max_len: usize,
+) -> LinePart {
+ let palette = help.style.colors;
+ let text_color = palette_match!(palette.text_unselected.base);
+ let styled_label = Style::new().dimmed().italic().paint(format!(" {}", label));
+ let mut key_bits: Vec<ANSIString> = vec![];
+ if keys.is_empty() {
+ key_bits.push(Style::new().dimmed().italic().paint("<unbound>"));
+ } else {
+ for (i, key) in keys.iter().enumerate() {
+ if i > 0 {
+ key_bits.push(Style::new().fg(text_color).paint(" + "));
+ }
+ key_bits.extend(style_key_with_modifier(
+ std::slice::from_ref(key),
+ &palette,
+ None,
+ ));
+ }
+ }
+ let mut bits: Vec<ANSIString> = vec![styled_label];
+ bits.extend(key_bits.clone());
+ let part = ANSIStrings(&bits);
+ let len = unstyled_len(&part);
+ if len <= max_len {
+ return LinePart {
+ part: part.to_string(),
+ len,
+ };
+ }
+ let part = ANSIStrings(&key_bits);
+ let len = unstyled_len(&part);
+ if len <= max_len {
+ LinePart {
+ part: part.to_string(),
+ len,
+ }
+ } else {
+ LinePart::default()
+ }
+}
+
pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart {
let hint = match copy_destination {
CopyDestination::Command => "Text piped to external command",
diff --git a/default-plugins/tab-bar/src/line.rs b/default-plugins/tab-bar/src/line.rs
index 84c5b3aa..d9f7a8da 100644
--- a/default-plugins/tab-bar/src/line.rs
+++ b/default-plugins/tab-bar/src/line.rs
@@ -11,6 +11,15 @@ fn get_current_title_len(current_title: &[LinePart]) -> usize {
current_title.iter().map(|p| p.len).sum()
}
+fn dim_style(bg: PaletteColor) -> ansi_term::Style {
+ ansi_term::Style::new()
+ .on(match bg {
+ PaletteColor::Rgb((r, g, b)) => ansi_term::Color::RGB(r, g, b),
+ PaletteColor::EightBit(c) => ansi_term::Color::Fixed(c),
+ })
+ .dimmed()
+}
+
// move elements from before_active and after_active into tabs_to_render while they fit in cols
// adds collapsed_tabs to the left and right if there's left over tabs that don't fit
fn populate_tabs_in_tab_line(
@@ -20,6 +29,7 @@ fn populate_tabs_in_tab_line(
cols: usize,
palette: Styling,
capabilities: PluginCapabilities,
+ dimmed: bool,
) {
let mut middle_size = get_current_title_len(tabs_to_render);
@@ -36,6 +46,7 @@ fn populate_tabs_in_tab_line(
palette,
tab_separator(capabilities),
left_more_tab_index,
+ dimmed,
);
// right_more_tab_index is the first tab to the right of the rightmost visible tab
@@ -45,6 +56,7 @@ fn populate_tabs_in_tab_line(
palette,
tab_separator(capabilities),
right_more_tab_index,
+ dimmed,
);
let total_size = collapsed_left.len + middle_size + collapsed_right.len;
@@ -113,6 +125,7 @@ fn left_more_message(
palette: Styling,
separator: &str,
tab_index: usize,
+ dimmed: bool,
) -> LinePart {
if tab_count_to_the_left == 0 {
return LinePart::default();
@@ -129,10 +142,13 @@ fn left_more_message(
palette.ribbon_unselected.base,
palette.text_unselected.background,
);
+ let text_style = if dimmed {
+ style!(text_color, palette.ribbon_unselected.background).italic()
+ } else {
+ style!(text_color, palette.ribbon_unselected.background).bold()
+ };
let left_separator = style!(sep_color, palette.ribbon_unselected.background).paint(separator);
- let more_styled_text = style!(text_color, palette.ribbon_unselected.background)
- .bold()
- .paint(more_text);
+ let more_styled_text = text_style.paint(more_text);
let right_separator = style!(palette.ribbon_unselected.background, sep_color).paint(separator);
let more_styled_text =
ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string();
@@ -148,6 +164,7 @@ fn right_more_message(
palette: Styling,
separator: &str,
tab_index: usize,
+ dimmed: bool,
) -> LinePart {
if tab_count_to_the_right == 0 {
return LinePart::default();
@@ -163,10 +180,13 @@ fn right_more_message(
palette.ribbon_unselected.base,
palette.text_unselected.background,
);
+ let text_style = if dimmed {
+ style!(text_color, palette.ribbon_unselected.background).italic()
+ } else {
+ style!(text_color, palette.ribbon_unselected.background).bold()
+ };
let left_separator = style!(sep_color, palette.ribbon_unselected.background).paint(separator);
- let more_styled_text = style!(text_color, palette.ribbon_unselected.background)
- .bold()
- .paint(more_text);
+ let more_styled_text = text_style.paint(more_text);
let right_separator = style!(palette.ribbon_unselected.background, sep_color).paint(separator);
let more_styled_text =
ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string();
@@ -177,23 +197,80 @@ fn right_more_message(
}
}
-fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) -> Vec<LinePart> {
+fn tab_line_prefix(
+ session_name: Option<&str>,
+ palette: Styling,
+ cols: usize,
+ dimmed: bool,
+ breadcrumb_ancestry: &[String],
+) -> (Vec<LinePart>, Option<(usize, usize)>) {
let prefix_text = " Zellij ".to_string();
let running_text_len = prefix_text.chars().count();
let text_color = palette.text_unselected.base;
let bg_color = palette.text_unselected.background;
- let prefix_styled_text = style!(text_color, bg_color).bold().paint(prefix_text);
+ let prefix_style = if dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(text_color, bg_color).bold()
+ };
+ let prefix_styled_text = prefix_style.paint(prefix_text);
let mut parts = vec![LinePart {
part: prefix_styled_text.to_string(),
len: running_text_len,
tab_index: None,
}];
+ let mut breadcrumb_range = None;
+ if !breadcrumb_ancestry.is_empty() {
+ if let Some(name) = session_name {
+ let ancestor_text = format!("({} ▸ ", breadcrumb_ancestry.join(" ▸ "));
+ let ancestor_len = ancestor_text.width();
+ let name_text = name.to_string();
+ let name_len = name_text.width();
+ let closing_text = ") ".to_string();
+ let closing_len = closing_text.width();
+ let ancestor_style = if dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(text_color, bg_color).italic()
+ };
+ let name_style = if dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(palette.text_unselected.emphasis_0, bg_color).bold()
+ };
+ let closing_style = if dimmed {
+ dim_style(bg_color)
+ } else {
+ style!(text_color, bg_color).bold()
+ };
+ if cols.saturating_sub(running_text_len) >= ancestor_len + name_len + closing_len {
+ let ancestor_start = running_text_len;
+ let ancestor_end = ancestor_start + ancestor_len;
+ breadcrumb_range = Some((ancestor_start, ancestor_end));
+ parts.push(LinePart {
+ part: ancestor_style.paint(ancestor_text).to_string(),
+ len: ancestor_len,
+ tab_index: None,
+ });
+ parts.push(LinePart {
+ part: name_style.paint(name_text).to_string(),
+ len: name_len,
+ tab_index: None,
+ });
+ parts.push(LinePart {
+ part: closing_style.paint(closing_text).to_string(),
+ len: closing_len,
+ tab_index: None,
+ });
+ }
+ }
+ return (parts, breadcrumb_range);
+ }
if let Some(name) = session_name {
let name_part = format!("({}) ", name);
let name_part_len = name_part.width();
- let text_color = palette.text_unselected.base;
- let name_part_styled_text = style!(text_color, bg_color).bold().paint(name_part);
+ let name_part_styled_text = prefix_style.paint(name_part);
if cols.saturating_sub(running_text_len) >= name_part_len {
parts.push(LinePart {
part: name_part_styled_text.to_string(),
@@ -202,7 +279,7 @@ fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) ->
})
}
}
- parts
+ (parts, breadcrumb_range)
}
pub fn tab_separator(capabilities: PluginCapabilities) -> &'static str {
@@ -229,7 +306,13 @@ pub fn tab_line(
hint: Option<&BTreeMap<usize, StyledText>>,
is_alternate_tab: bool,
new_tab_button_is_hovered: bool,
-) -> (Vec<LinePart>, Option<(usize, usize)>) {
+ dimmed: bool,
+ breadcrumb_ancestry: &[String],
+) -> (
+ Vec<LinePart>,
+ Option<(usize, usize)>,
+ Option<(usize, usize)>,
+) {
let mut tabs_after_active = all_tabs.split_off(active_tab_index);
let mut tabs_before_active = all_tabs;
let active_tab = if !tabs_after_active.is_empty() {
@@ -237,9 +320,9 @@ pub fn tab_line(
} else {
tabs_before_active.pop().unwrap()
};
- let mut prefix = match hide_session_name {
- true => tab_line_prefix(None, palette, cols),
- false => tab_line_prefix(session_name, palette, cols),
+ let (mut prefix, breadcrumb_range) = match hide_session_name {
+ true => tab_line_prefix(None, palette, cols, dimmed, &[]),
+ false => tab_line_prefix(session_name, palette, cols, dimmed, breadcrumb_ancestry),
};
let mut swap_layout_indicator = if hide_swap_layout_indicator {
@@ -251,17 +334,19 @@ pub fn tab_line(
tab_info.is_swap_layout_dirty,
mode_info,
!capabilities.arrow_fonts,
+ dimmed,
)
})
};
if swap_layout_indicator.is_none() {
- swap_layout_indicator = active_pane_scroll.map(|scroll| scroll_status(scroll, palette));
+ swap_layout_indicator =
+ active_pane_scroll.map(|scroll| scroll_status(scroll, palette, dimmed));
}
// Drop indicator if it would cause wrapping (active tab always rendered unconditionally)
let prefix_len = get_current_title_len(&prefix);
let hint_budget = cols.saturating_sub(prefix_len + active_tab.len);
- if let Some(hint_part) = hint.and_then(|hint| hint_line_part(hint, hint_budget)) {
+ if let Some(hint_part) = hint.and_then(|hint| hint_line_part(hint, hint_budget, dimmed)) {
swap_layout_indicator = Some(hint_part);
}
let indicator_len = swap_layout_indicator.as_ref().map(|s| s.len).unwrap_or(0);
@@ -280,6 +365,7 @@ pub fn tab_line(
cols.saturating_sub(non_tab_len),
palette,
capabilities,
+ dimmed,
);
prefix.append(&mut tabs_to_render);
prefix.append(&mut vec![LinePart {
@@ -303,7 +389,8 @@ pub fn tab_line(
} else {
palette.ribbon_unselected.background
};
- let new_tab_button = new_tab_button_line_part(palette, capabilities, new_tab_button_background);
+ let new_tab_button =
+ new_tab_button_line_part(palette, capabilities, new_tab_button_background, dimmed);
let used = prefix.iter().map(|p| p.len).sum::<usize>();
let current_indicator_len = swap_layout_indicator.as_ref().map(|s| s.len).unwrap_or(0);
let room = cols
@@ -340,7 +427,7 @@ pub fn tab_line(
prefix.push(swap_layout_indicator);
}
- (prefix, new_tab_button_range)
+ (prefix, new_tab_button_range, breadcrumb_range)
}
fn swap_layout_status(
@@ -348,6 +435,7 @@ fn swap_layout_status(
is_swap_layout_dirty: bool,
mode_info: &ModeInfo,
supports_arrow_fonts: bool,
+ dimmed: bool,
) -> Option<LinePart> {
match swap_layout_name {
Some(swap_layout_name) => {
@@ -356,11 +444,12 @@ fn swap_layout_status(
&mode_keybinds,
&[&[Action::PreviousSwapLayout], &[Action::NextSwapLayout]],
);
- let mut text = style_key_with_modifier(&prev_next_keys, Some(0));
+ let mut text = style_key_with_modifier(&prev_next_keys, Some(0), dimmed);
text.append(&ribbon_as_line_part(
&swap_layout_name.to_uppercase(),
!is_swap_layout_dirty,
supports_arrow_fonts,
+ dimmed,
));
Some(text)
},
@@ -368,13 +457,22 @@ fn swap_layout_status(
}
}
-fn hint_line_part(hint: &BTreeMap<usize, StyledText>, max_len: usize) -> Option<LinePart> {
+fn hint_line_part(
+ hint: &BTreeMap<usize, StyledText>,
+ max_len: usize,
+ dimmed: bool,
+) -> Option<LinePart> {
let fitting_variant = hint
.range(..=max_len)
.next_back()
.map(|(_width, styled_text)| styled_text)?;
let len = fitting_variant.text.width();
- let part = serialize_text(&Text::from(fitting_variant.clone()));
+ let text = if dimmed {
+ Text::from(fitting_variant.clone()).disabled()
+ } else {
+ Text::from(fitting_variant.clone())
+ };
+ let part = serialize_text(&text);
Some(LinePart {
part,
len,
@@ -382,12 +480,17 @@ fn hint_line_part(hint: &BTreeMap<usize, StyledText>, max_len: usize) -> Option<
})
}
-fn scroll_status(scroll: (usize, usize), palette: Styling) -> LinePart {
+fn scroll_status(scroll: (usize, usize), palette: Styling, dimmed: bool) -> LinePart {
let (position, length) = scroll;
let text = format!("[ SCROLL {}/{} ]", position, length);
let text_color = palette.text_unselected.base;
let bg_color = palette.text_unselected.background;
- let styled_text = style!(text_color, bg_color).bold().paint(&text);
+ let text_style = if dimmed {
+ style!(text_color, bg_color).italic()
+ } else {
+ style!(text_color, bg_color).bold()
+ };
+ let styled_text = text_style.paint(&text);
LinePart {
part: styled_text.to_string(),
len: text.width(),
@@ -399,17 +502,26 @@ fn new_tab_button_line_part(
palette: Styling,
capabilities: PluginCapabilities,
background_color: PaletteColor,
+ dimmed: bool,
) -> LinePart {
let separator = tab_separator(capabilities);
let separator_width = separator.width();
let foreground_color = palette.ribbon_unselected.base;
let separator_fill_color = palette.text_unselected.background;
+ let background_color = if dimmed {
+ palette.ribbon_unselected.background
+ } else {
+ background_color
+ };
+ let text_style = if dimmed {
+ style!(foreground_color, background_color).italic()
+ } else {
+ style!(foreground_color, background_color).bold()
+ };
let left_separator = style!(separator_fill_color, background_color).paint(separator);
let right_separator = style!(background_color, separator_fill_color).paint(separator);
let text = "+";
- let styled_text = style!(foreground_color, background_color)
- .bold()
- .paint(format!(" {} ", text));
+ let styled_text = text_style.paint(format!(" {} ", text));
let part = ANSIStrings(&[left_separator, styled_text, right_separator]).to_string();
let len = text.width() + (separator_width * 2) + 2;
LinePart {
@@ -419,8 +531,15 @@ fn new_tab_button_line_part(
}
}
-pub fn ribbon_as_line_part(text: &str, is_selected: bool, supports_arrow_fonts: bool) -> LinePart {
- let ribbon_text = if is_selected {
+pub fn ribbon_as_line_part(
+ text: &str,
+ is_selected: bool,
+ supports_arrow_fonts: bool,
+ dimmed: bool,
+) -> LinePart {
+ let ribbon_text = if dimmed {
+ Text::new(text).disabled()
+ } else if is_selected {
Text::new(text).selected()
} else {
Text::new(text)
@@ -437,7 +556,11 @@ pub fn ribbon_as_line_part(text: &str, is_selected: bool, supports_arrow_fonts:
}
}
-pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize>) -> LinePart {
+pub fn style_key_with_modifier(
+ keyvec: &[KeyWithModifier],
+ color_index: Option<usize>,
+ dimmed: bool,
+) -> LinePart {
if keyvec.is_empty() {
return LinePart::default();
}
@@ -477,7 +600,9 @@ pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<u
if no_common_modifier || key.len() == 1 {
let key_string_text = format!(" {} ", key.join(key_separator));
- let text = if let Some(color_index) = color_index {
+ let text = if dimmed {
+ Text::new(&key_string_text).disabled().opaque()
+ } else if let Some(color_index) = color_index {
Text::new(&key_string_text)
.color_range(color_index, ..)
.opaque()
@@ -492,7 +617,9 @@ pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<u
} else {
let key_string_without_modifier = format!("{}", key.join(key_separator));
let key_string_text = format!(" {} <{}> ", modifier_str, key_string_without_modifier);
- let text = if let Some(color_index) = color_index {
+ let text = if dimmed {
+ Text::new(&key_string_text).disabled().opaque()
+ } else if let Some(color_index) = color_index {
Text::new(&key_string_text)
.color_range(color_index, ..modifier_str.width() + 1)
.color_range(
diff --git a/default-plugins/tab-bar/src/main.rs b/default-plugins/tab-bar/src/main.rs
index 4b521969..d55cd800 100644
--- a/default-plugins/tab-bar/src/main.rs
+++ b/default-plugins/tab-bar/src/main.rs
@@ -35,6 +35,7 @@ struct State {
cached_keybinds: KeybindsVec,
active_pane_scroll: Option<(usize, usize)>,
new_tab_button_range: Option<(usize, usize)>,
+ breadcrumb_range: Option<(usize, usize)>,
hovered_tab_idx: Option<usize>,
hovered_new_tab_button: bool,
hint_text: Option<BTreeMap<usize, StyledText>>,
@@ -133,6 +134,12 @@ impl ZellijPlugin for State {
},
Event::Mouse(me) => match me {
Mouse::LeftClick(_, col) => {
+ if let Some((start, end)) = self.breadcrumb_range {
+ if col >= start && col < end {
+ focus_host_session();
+ return should_render;
+ }
+ }
if let Some((start, end)) = self.new_tab_button_range {
if col >= start && col < end {
new_tab::<&str>(None, None);
@@ -191,6 +198,8 @@ impl ZellijPlugin for State {
if self.tabs.is_empty() {
return;
}
+ let dimmed = self.mode_info.session_ascended == Some(true)
+ || self.mode_info.session_dimmed == Some(true);
let mut all_tabs: Vec<LinePart> = vec![];
let mut active_tab_index = 0;
let mut is_alternate_tab = false;
@@ -212,6 +221,7 @@ impl ZellijPlugin for State {
is_hovered,
self.mode_info.style.colors,
self.mode_info.capabilities,
+ dimmed,
);
is_alternate_tab = !is_alternate_tab;
all_tabs.push(tab);
@@ -225,7 +235,12 @@ impl ZellijPlugin for State {
} else {
self.hint_text.as_ref()
};
- let (line, new_tab_button_range) = tab_line(
+ let breadcrumb_ancestry: Vec<String> = if self.mode_info.host_fullscreen == Some(true) {
+ self.mode_info.session_ancestry.clone()
+ } else {
+ vec![]
+ };
+ let (line, new_tab_button_range, breadcrumb_range) = tab_line(
self.mode_info.session_name.as_deref(),
all_tabs,
active_tab_index,
@@ -241,9 +256,12 @@ impl ZellijPlugin for State {
hint_text,
is_alternate_tab,
self.hovered_new_tab_button,
+ dimmed,
+ &breadcrumb_ancestry,
);
self.tab_line = line;
self.new_tab_button_range = new_tab_button_range;
+ self.breadcrumb_range = breadcrumb_range;
let output = self
.tab_line
diff --git a/default-plugins/tab-bar/src/tab.rs b/default-plugins/tab-bar/src/tab.rs
index 3db1bbb6..df44dbad 100644
--- a/default-plugins/tab-bar/src/tab.rs
+++ b/default-plugins/tab-bar/src/tab.rs
@@ -27,6 +27,7 @@ pub fn render_tab(
is_hovered: bool,
palette: Styling,
separator: &str,
+ dimmed: bool,
) -> LinePart {
let focused_clients = tab.other_focused_clients.as_slice();
let separator_width = separator.width();
@@ -58,27 +59,30 @@ pub fn render_tab(
};
let separator_fill_color = palette.text_unselected.background;
+ let background_color = if dimmed {
+ palette.ribbon_unselected.background
+ } else {
+ background_color
+ };
+ let text_style = if dimmed {
+ style!(palette.ribbon_unselected.base, background_color).italic()
+ } else {
+ style!(foreground_color, background_color).bold()
+ };
let left_separator = style!(separator_fill_color, background_color).paint(separator);
+ let right_separator_style = style!(background_color, separator_fill_color);
let mut tab_text_len = text.width() + (separator_width * 2) + 2; // +2 for padding
- let tab_styled_text = style!(foreground_color, background_color)
- .bold()
- .paint(format!(" {} ", text));
+ let tab_styled_text = text_style.paint(format!(" {} ", text));
- let right_separator = style!(background_color, separator_fill_color).paint(separator);
+ let right_separator = right_separator_style.paint(separator);
let tab_styled_text = if !focused_clients.is_empty() {
let (cursor_section, extra_length) =
cursors(focused_clients, palette.multiplayer_user_colors);
tab_text_len += extra_length + 2; // 2 for cursor_beginning and cursor_end
let mut s = String::new();
- let cursor_beginning = style!(foreground_color, background_color)
- .bold()
- .paint("[")
- .to_string();
+ let cursor_beginning = text_style.paint("[").to_string();
let cursor_section = ANSIStrings(&cursor_section).to_string();
- let cursor_end = style!(foreground_color, background_color)
- .bold()
- .paint("]")
- .to_string();
+ let cursor_end = text_style.paint("]").to_string();
s.push_str(&left_separator.to_string());
s.push_str(&tab_styled_text.to_string());
s.push_str(&cursor_beginning);
@@ -104,6 +108,7 @@ pub fn tab_style(
is_hovered: bool,
palette: Styling,
capabilities: PluginCapabilities,
+ dimmed: bool,
) -> LinePart {
let separator = tab_separator(capabilities);
@@ -127,6 +132,7 @@ pub fn tab_style(
is_hovered,
palette,
separator,
+ dimmed,
)
}
diff --git a/example/alt-centered-config.kdl b/example/alt-centered-config.kdl
index b9803880..7f579432 100644
--- a/example/alt-centered-config.kdl
+++ b/example/alt-centered-config.kdl
@@ -46,6 +46,7 @@ keybinds {
bind "n" { NewPane; }
bind "x" { CloseFocus; }
bind "f" { ToggleFocusFullscreen; }
+ bind "Shift f" { ToggleFocusNoUiFullscreen; }
bind "z" { TogglePaneFrames; }
}
diff --git a/example/config.kdl b/example/config.kdl
index cbfb7a63..e73a3677 100644
--- a/example/config.kdl
+++ b/example/config.kdl
@@ -29,6 +29,7 @@ keybinds {
bind "r" { NewPane "Right"; SwitchToMode "Normal"; }
bind "x" { CloseFocus; SwitchToMode "Normal"; }
bind "f" { ToggleFocusFullscreen; SwitchToMode "Normal"; }
+ bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }
bind "z" { TogglePaneFrames; SwitchToMode "Normal"; }
bind "w" { ToggleFloatingPanes; SwitchToMode "Normal"; }
bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }
diff --git a/example/default.kdl b/example/default.kdl
index eb198c94..614bcf96 100644
--- a/example/default.kdl
+++ b/example/default.kdl
@@ -33,6 +33,7 @@ keybinds {
bind "s" { NewPane "stacked"; SwitchToMode "Normal"; }
bind "x" { CloseFocus; SwitchToMode "Normal"; }
bind "f" { ToggleFocusFullscreen; SwitchToMode "Normal"; }
+ bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }
bind "z" { TogglePaneFrames; SwitchToMode "Normal"; }
bind "w" { ToggleFloatingPanes; SwitchToMode "Normal"; }
bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }
diff --git a/xtask/src/build.rs b/xtask/src/build.rs
index d2469b59..210b8e4b 100644
--- a/xtask/src/build.rs
+++ b/xtask/src/build.rs
@@ -45,30 +45,14 @@ pub fn build(sh: &Shell, flags: flags::Build) -> anyhow::Result<()> {
crate::status(msg);
eprintln!("{}", msg);
- let mut base_cmd = cmd!(sh, "{cargo} build --target wasm32-wasip1");
- if flags.release {
- base_cmd = base_cmd.arg("--release");
- }
- for member in &plugin_members {
- let plugin_name = member
- .crate_name
- .rsplit_once('/')
- .context("Cannot determine plugin name from crate path")?
- .1;
- base_cmd = base_cmd.args(["-p", plugin_name]);
- }
- base_cmd.run().context("failed to build plugins")?;
-
if flags.release {
+ build_plugins_release_into_assets(sh, &plugin_members)?;
+ } else {
+ let mut base_cmd = cmd!(sh, "{cargo} build --target wasm32-wasip1");
for member in &plugin_members {
- let plugin_name = member
- .crate_name
- .rsplit_once('/')
- .context("Cannot determine plugin name from crate path")?
- .1;
- move_plugin_to_assets(sh, plugin_name)?;
+ base_cmd = base_cmd.args(["-p", plugin_name_of(member)?]);
}
- } else {
+ base_cmd.run().context("failed to build plugins")?;
write_plugin_stamp(sh);
}
}
@@ -126,40 +110,30 @@ fn plugins_force() -> bool {
std::env::var_os("ZELLIJ_FORCE_PLUGINS").is_some()
}
-fn plugin_stamp_path() -> PathBuf {
- PathBuf::from(
- std::env::var_os("CARGO_TARGET_DIR")
- .unwrap_or(crate::project_root().join("target").into_os_string()),
- )
- .join(".xtask-plugins-stamp")
-}
-
-fn write_plugin_stamp(sh: &Shell) {
- let _ = sh.write_file(plugin_stamp_path(), b"");
+fn plugin_asset_path(plugin_name: &str) -> PathBuf {
+ crate::asset_dir()
+ .join("plugins")
+ .join(plugin_name)
+ .with_extension("wasm")
}
-fn plugin_sources_changed() -> bool {
- let stamp_time = match std::fs::metadata(plugin_stamp_path()).and_then(|m| m.modified()) {
- Ok(stamp_time) => stamp_time,
- Err(_) => return true,
- };
+fn newest_plugin_source_time() -> Option<std::time::SystemTime> {
let root = crate::project_root();
["default-plugins", "zellij-tile", "zellij-tile-utils"]
.iter()
- .any(|dir| dir_changed_since(&root.join(dir), stamp_time))
+ .filter_map(|dir| newest_file_time(&root.join(dir)))
+ .max()
}
-fn dir_changed_since(dir: &Path, since: std::time::SystemTime) -> bool {
- let entries = match std::fs::read_dir(dir) {
- Ok(entries) => entries,
- Err(_) => return false,
- };
+fn newest_file_time(dir: &Path) -> Option<std::time::SystemTime> {
+ let entries = std::fs::read_dir(dir).ok()?;
+ let mut newest: Option<std::time::SystemTime> = None;
for entry in entries.flatten() {
+ let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
- let path = entry.path();
if file_type.is_dir() {
if path
.file_name()
@@ -168,16 +142,102 @@ fn dir_changed_since(dir: &Path, since: std::time::SystemTime) -> bool {
{
continue;
}
- if dir_changed_since(&path, since) {
- return true;
+ if let Some(child) = newest_file_time(&path) {
+ newest = Some(newest.map_or(child, |current| current.max(child)));
}
} else if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) {
- if modified > since {
- return true;
- }
+ newest = Some(newest.map_or(modified, |current| current.max(modified)));
}
}
- false
+ newest
+}
+
+pub fn ensure_plugin_assets(sh: &Shell) -> anyhow::Result<()> {
+ let plugin_members: Vec<&WorkspaceMember> = crate::workspace_members()
+ .iter()
+ .filter(|m| m.build && m.crate_name.contains("plugins"))
+ .collect();
+ if plugin_members.is_empty() {
+ return Ok(());
+ }
+
+ let newest_source = newest_plugin_source_time();
+ let stale = plugins_force()
+ || plugin_members.iter().any(|member| {
+ let plugin_name = match member.crate_name.rsplit_once('/') {
+ Some((_, name)) => name,
+ None => return true,
+ };
+ let asset_time = std::fs::metadata(plugin_asset_path(plugin_name))
+ .and_then(|m| m.modified())
+ .ok();
+ match (asset_time, newest_source) {
+ (Some(asset_time), Some(newest_source)) => asset_time < newest_source,
+ _ => true,
+ }
+ });
+
+ if !stale {
+ let msg = ">> Plugin assets up to date, skipping plugin build";
+ crate::status(msg);
+ eprintln!("{}", msg);
+ return Ok(());
+ }
+
+ let msg = ">> Building plugin assets (release)";
+ crate::status(msg);
+ eprintln!("{}", msg);
+
+ build_plugins_release_into_assets(sh, &plugin_members)
+}
+
+fn build_plugins_release_into_assets(
+ sh: &Shell,
+ plugin_members: &[&WorkspaceMember],
+) -> anyhow::Result<()> {
+ let cargo = crate::cargo()?;
+ let mut base_cmd = cmd!(sh, "{cargo} build --target wasm32-wasip1 --release");
+ for member in plugin_members {
+ let plugin_name = plugin_name_of(member)?;
+ base_cmd = base_cmd.args(["-p", plugin_name]);
+ }
+ base_cmd.run().context("failed to build plugin assets")?;
+
+ for member in plugin_members {
+ move_plugin_to_assets(sh, plugin_name_of(member)?)?;
+ }
+ Ok(())
+}
+
+fn plugin_name_of(member: &WorkspaceMember) -> anyhow::Result<&'static str> {
+ Ok(member
+ .crate_name
+ .rsplit_once('/')
+ .context("Cannot determine plugin name from crate path")?
+ .1)
+}
+
+fn plugin_stamp_path() -> PathBuf {
+ PathBuf::from(
+ std::env::var_os("CARGO_TARGET_DIR")
+ .unwrap_or(crate::project_root().join("target").into_os_string()),
+ )
+ .join(".xtask-plugins-stamp")
+}
+
+fn write_plugin_stamp(sh: &Shell) {
+ let _ = sh.write_file(plugin_stamp_path(), b"");
+}
+
+fn plugin_sources_changed() -> bool {
+ let stamp_time = match std::fs::metadata(plugin_stamp_path()).and_then(|m| m.modified()) {
+ Ok(stamp_time) => stamp_time,
+ Err(_) => return true,
+ };
+ match newest_plugin_source_time() {
+ Some(newest_source) => newest_source > stamp_time,
+ None => true,
+ }
}
pub fn proto(sh: &Shell) -> anyhow::Result<()> {
@@ -205,6 +265,11 @@ fn run_proto_codegen(sh: &Shell, force: bool) {
"src/web_server_contract",
"generated_web_server_api.rs",
),
+ (
+ "assets/prost_nested_session",
+ "src/nested_session_contract",
+ "generated_nested_session_api.rs",
+ ),
];
for (out_subdir, src_subdir, include_file) in specs {
diff --git a/xtask/src/integration_test.rs b/xtask/src/integration_test.rs
index 88eb1154..077ed53d 100644
--- a/xtask/src/integration_test.rs
+++ b/xtask/src/integration_test.rs
@@ -12,6 +12,8 @@ pub fn integration_test(sh: &Shell, flags: flags::IntegrationTest) -> anyhow::Re
crate::status(msg);
println!("{}", msg);
+ crate::build::ensure_plugin_assets(sh).context(err_context)?;
+
let profile = if flags.no_opt { "dev" } else { "dev-opt" };
let nextest_available = cmd!(sh, "{cargo} nextest --version")
diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs
index 45e8d9ce..3c9d269c 100644
--- a/zellij-client/src/input_handler.rs
+++ b/zellij-client/src/input_handler.rs
@@ -1,7 +1,8 @@
//! Main input logic.
use crate::{
- os_input_output::ClientOsApi, stdin_ansi_parser::AnsiStdinInstruction, ClientId,
- ClientInstruction, CommandIsExecuting, InputInstruction,
+ nested_reannounce::NestedReannounce, os_input_output::ClientOsApi,
+ stdin_ansi_parser::AnsiStdinInstruction, ClientId, ClientInstruction, CommandIsExecuting,
+ InputInstruction,
};
use zellij_utils::{
channels::{Receiver, SenderWithContext, OPENCALLS},
@@ -15,6 +16,7 @@ use zellij_utils::{
options::Options,
},
ipc::{ClientToServerMsg, ExitReason},
+ nested_session::{self, NestedSessionMessage},
position::Position,
vendored::termwiz::input::{
InputEvent, Modifiers, MouseButtons, MouseEvent as TermwizMouseEvent,
@@ -35,6 +37,7 @@ struct InputHandler {
receive_input_instructions: Receiver<(InputInstruction, ErrorContext)>,
mouse_old_event: MouseEvent,
mouse_mode_active: bool,
+ nested_reannounce: Option<NestedReannounce>,
}
fn termwiz_mouse_convert(original_event: &mut MouseEvent, event: &TermwizMouseEvent) {
@@ -136,6 +139,7 @@ impl InputHandler {
mode: InputMode, // TODO: we can probably get rid of this now that we're tracking it on the
// server instead
receive_input_instructions: Receiver<(InputInstruction, ErrorContext)>,
+ nested_reannounce: Option<NestedReannounce>,
) -> Self {
InputHandler {
mode,
@@ -148,6 +152,7 @@ impl InputHandler {
receive_input_instructions,
mouse_old_event: MouseEvent::new(),
mouse_mode_active: false,
+ nested_reannounce,
}
}
@@ -269,6 +274,12 @@ impl InputHandler {
reply_bytes,
});
},
+ Ok((
+ InputInstruction::NestedSessionFrameFromHost(payload_bytes),
+ _error_context,
+ )) => {
+ self.handle_nested_session_frame_from_host(payload_bytes);
+ },
Ok((InputInstruction::Exit, _error_context)) => {
self.should_exit = true;
},
@@ -329,6 +340,29 @@ impl InputHandler {
},
}
}
+ fn handle_nested_session_frame_from_host(&mut self, payload_bytes: Vec<u8>) {
+ if let Some(nested_reannounce) = &self.nested_reannounce {
+ nested_reannounce.note_host_contact();
+ }
+ match nested_session::decode_payload(&payload_bytes) {
+ Some(NestedSessionMessage::Ping) => {
+ self.send_client_instructions
+ .send(ClientInstruction::EmitNestedSessionFrame(
+ nested_session::encode_payload(&NestedSessionMessage::Pong),
+ ))
+ .unwrap();
+ },
+ Some(_) => {
+ self.os_input
+ .send_to_server(ClientToServerMsg::NestedSessionFrameFromHost {
+ payload_bytes,
+ });
+ },
+ None => {
+ log::debug!("dropping undecodable nested session frame from host");
+ },
+ }
+ }
fn handle_mouse_event(&mut self, mouse_event: &MouseEvent) {
self.dispatch_action(
Action::MouseEvent {
@@ -450,6 +484,7 @@ pub(crate) fn input_loop(
send_client_instructions: SenderWithContext<ClientInstruction>,
default_mode: InputMode,
receive_input_instructions: Receiver<(InputInstruction, ErrorContext)>,
+ nested_reannounce: Option<NestedReannounce>,
) {
let _handler = InputHandler::new(
os_input,
@@ -459,6 +494,7 @@ pub(crate) fn input_loop(
send_client_instructions,
default_mode,
receive_input_instructions,
+ nested_reannounce,
)
.handle_input();
}
diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs
index 2db4617f..1df213a2 100644
--- a/zellij-client/src/lib.rs
+++ b/zellij-client/src/lib.rs
@@ -11,6 +11,7 @@ pub mod cli_client;
mod command_is_executing;
mod input_handler;
mod keyboard_parser;
+mod nested_reannounce;
pub mod old_config_converter;
#[cfg(feature = "web_server_capability")]
pub mod remote_attach;
@@ -157,6 +158,7 @@ use zellij_utils::{
errors::{ClientContext, ContextType, ErrorInstruction},
input::{cli_assets::CliAssets, config::Config, options::Options},
ipc::{ClientToServerMsg, ExitReason, ResizeCause, ServerToClientMsg},
+ nested_session,
pane_size::Size,
vendored::termwiz::input::InputEvent,
};
@@ -186,6 +188,7 @@ pub(crate) enum ClientInstruction {
token: u32,
query_bytes: Vec<u8>,
},
+ EmitNestedSessionFrame(Vec<u8>),
}
impl From<ServerToClientMsg> for ClientInstruction {
@@ -211,6 +214,9 @@ impl From<ServerToClientMsg> for ClientInstruction {
ServerToClientMsg::ForwardQueryToHost { token, query_bytes } => {
ClientInstruction::ForwardQueryToHost { token, query_bytes }
},
+ ServerToClientMsg::EmitNestedSessionFrame { payload_bytes } => {
+ ClientInstruction::EmitNestedSessionFrame(payload_bytes)
+ },
// Subscribe-only messages — not handled by regular interactive clients
ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread,
ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread,
@@ -238,6 +244,7 @@ impl From<&ClientInstruction> for ClientContext {
ClientInstruction::RenamedSession(..) => ClientContext::RenamedSession,
ClientInstruction::ConfigFileUpdated => ClientContext::ConfigFileUpdated,
ClientInstruction::ForwardQueryToHost { .. } => ClientContext::ForwardQueryToHost,
+ ClientInstruction::EmitNestedSessionFrame(..) => ClientContext::EmitNestedSessionFrame,
}
}
}
@@ -523,6 +530,7 @@ pub(crate) enum InputInstruction {
token: u32,
reply_bytes: Vec<u8>,
},
+ NestedSessionFrameFromHost(Vec<u8>),
Exit,
}
@@ -530,6 +538,7 @@ pub(crate) enum InputInstruction {
pub async fn run_remote_client_terminal_loop(
os_input: Box<dyn ClientOsApi>,
mut connections: remote_attach::WebSocketConnections,
+ nested_session_name: Option<String>,
) -> Result<Option<ConnectToSession>, RemoteClientError> {
use crate::os_input_output::{AsyncSignals, AsyncStdin};
@@ -563,15 +572,52 @@ pub async fn run_remote_client_terminal_loop(
log::error!("Failed to send resize message: {}", e);
}
+ let mut nested_frame_extractor = nested_session::NestedFrameExtractor::new();
+ let mut last_heard_from_host = std::time::Instant::now();
+ let mut reannounce_check = tokio::time::interval(std::time::Duration::from_millis(
+ nested_session::reannounce_check_interval_ms(),
+ ));
+ reannounce_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+
loop {
tokio::select! {
// Handle stdin input
result = async_stdin.read() => {
match result {
Ok(buf) if !buf.is_empty() => {
- if let Err(e) = connections.terminal_ws.send(Message::Binary(buf)).await {
- log::error!("Failed to send stdin to terminal WebSocket: {}", e);
- break;
+ let (cleaned, nested_frames) = nested_frame_extractor.extract(&buf);
+ for payload_bytes in nested_frames {
+ last_heard_from_host = std::time::Instant::now();
+ match nested_session::decode_payload(&payload_bytes) {
+ Some(nested_session::NestedSessionMessage::Ping) => {
+ let mut stdout = os_input.get_stdout_writer();
+ let _ = stdout.write_all(&nested_session::encode_frame(
+ &nested_session::NestedSessionMessage::Pong,
+ ));
+ let _ = stdout.flush();
+ },
+ Some(_) => {
+ let control_msg = Message::Text(
+ serde_json::to_string(&WebClientToWebServerControlMessage {
+ web_client_id: connections.web_client_id.clone(),
+ payload: WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost {
+ payload_bytes,
+ },
+ })
+ .unwrap(),
+ );
+ if let Err(e) = connections.control_ws.send(control_msg).await {
+ log::error!("Failed to forward nested session frame over control WebSocket: {}", e);
+ }
+ },
+ None => {},
+ }
+ }
+ if !cleaned.is_empty() {
+ if let Err(e) = connections.terminal_ws.send(Message::Binary(cleaned)).await {
+ log::error!("Failed to send stdin to terminal WebSocket: {}", e);
+ break;
+ }
}
}
Ok(_) => {
@@ -585,6 +631,26 @@ pub async fn run_remote_client_terminal_loop(
}
}
+ _ = reannounce_check.tick() => {
+ if let Some(session_name) = &nested_session_name {
+ if last_heard_from_host.elapsed()
+ >= std::time::Duration::from_millis(nested_session::reannounce_silence_ms())
+ {
+ let announce = nested_session::NestedSessionMessage::Announce {
+ session_name: session_name.clone(),
+ capabilities: vec![nested_session::NestedSessionCapability::NestedControl],
+ };
+ let mut stdout = os_input.get_stdout_writer();
+ if stdout
+ .write_all(&nested_session::encode_frame(&announce))
+ .is_ok()
+ {
+ let _ = stdout.flush();
+ }
+ }
+ }
+ }
+
// Handle signals
Some(signal) = async_signals.recv() => {
match signal {
@@ -721,6 +787,14 @@ pub fn start_remote_client(
) -> Result<Option<ConnectToSession>, RemoteClientError> {
info!("Starting Zellij client!");
+ let is_nested_inside_zellij_pane = os_input.env_variable("ZELLIJ").is_some();
+ let remote_session_name = remote_session_url
+ .trim_end_matches('/')
+ .rsplit('/')
+ .next()
+ .unwrap_or_default()
+ .to_owned();
+
let runtime = crate::async_runtime(async_worker_tasks);
let connections = remote_attach::attach_to_remote_session(
@@ -756,6 +830,16 @@ pub fn start_remote_client(
os_input.set_raw_mode();
stdout.write_all(ENABLE_BRACKETED_PASTE.as_bytes()).unwrap();
+ if is_nested_inside_zellij_pane {
+ let announce = nested_session::NestedSessionMessage::Announce {
+ session_name: remote_session_name.clone(),
+ capabilities: vec![nested_session::NestedSessionCapability::NestedControl],
+ };
+ stdout
+ .write_all(&nested_session::encode_frame(&announce))
+ .unwrap();
+ let _ = stdout.flush();
+ }
std::panic::set_hook({
use zellij_utils::errors::handle_panic;
@@ -785,11 +869,25 @@ pub fn start_remote_client(
std::process::exit(exit_status);
};
+ let nested_session_name = if is_nested_inside_zellij_pane {
+ Some(remote_session_name.clone())
+ } else {
+ None
+ };
runtime.block_on(run_remote_client_terminal_loop(
os_input.clone(),
connections,
+ nested_session_name,
))?;
+ if is_nested_inside_zellij_pane {
+ let mut stdout = os_input.get_stdout_writer();
+ let _ = stdout.write_all(&nested_session::encode_frame(
+ &nested_session::NestedSessionMessage::Bye,
+ ));
+ let _ = stdout.flush();
+ }
+
let exit_msg = String::from("Bye from Zellij!");
if reconnect_to_session.is_none() {
@@ -822,6 +920,9 @@ pub fn start_client(
}
info!("Starting Zellij client!");
+ let is_nested_inside_zellij_pane = os_input.env_variable("ZELLIJ").is_some();
+ let own_session_name = info.get_session_name().to_owned();
+
let explicitly_disable_kitty_keyboard_protocol = config_options
.support_kitty_keyboard_protocol
.map(|e| !e)
@@ -1043,6 +1144,22 @@ pub fn start_client(
os_input.set_raw_mode();
let mut stdout = os_input.get_stdout_writer();
stdout.write_all(ENABLE_BRACKETED_PASTE.as_bytes()).unwrap();
+ let nested_reannounce = if is_nested_inside_zellij_pane {
+ let announce = nested_session::NestedSessionMessage::Announce {
+ session_name: own_session_name.clone(),
+ capabilities: vec![nested_session::NestedSessionCapability::NestedControl],
+ };
+ stdout
+ .write_all(&nested_session::encode_frame(&announce))
+ .unwrap();
+ let _ = stdout.flush();
+ Some(crate::nested_reannounce::NestedReannounce::spawn(
+ os_input.clone(),
+ own_session_name.clone(),
+ ))
+ } else {
+ None
+ };
let (send_client_instructions, receive_client_instructions): ChannelWithContext<
ClientInstruction,
@@ -1116,6 +1233,7 @@ pub fn start_client(
let command_is_executing = command_is_executing.clone();
let os_input = os_input.clone();
let default_mode = config_options.default_mode.unwrap_or_default();
+ let nested_reannounce = nested_reannounce.clone();
move || {
input_loop(
os_input,
@@ -1125,6 +1243,7 @@ pub fn start_client(
send_client_instructions,
default_mode,
receive_input_instructions,
+ nested_reannounce,
)
}
});
@@ -1142,6 +1261,10 @@ pub fn start_client(
new_size: os_api.get_terminal_size(),
cause: ResizeCause::Viewport,
});
+ #[cfg(not(windows))]
+ let _ = os_api
+ .get_stdout_writer()
+ .write(crate::stdin_handler::PIXEL_SIZE_QUERY.as_bytes());
}
}),
Box::new({
@@ -1340,12 +1463,32 @@ pub fn start_client(
let _ = out.write_all(&blob);
let _ = out.flush();
},
+ ClientInstruction::EmitNestedSessionFrame(payload_bytes) => {
+ if is_nested_inside_zellij_pane {
+ let frame = nested_session::encode_frame_from_payload(&payload_bytes);
+ let mut out = os_input.get_stdout_writer();
+ let _ = out.write_all(&frame);
+ let _ = out.flush();
+ }
+ },
_ => {},
}
}
+ if let Some(nested_reannounce) = &nested_reannounce {
+ nested_reannounce.stop();
+ }
+
router_thread.join().unwrap();
+ if is_nested_inside_zellij_pane {
+ let mut stdout = os_input.get_stdout_writer();
+ let _ = stdout.write_all(&nested_session::encode_frame(
+ &nested_session::NestedSessionMessage::Bye,
+ ));
+ let _ = stdout.flush();
+ }
+
if reconnect_to_session.is_none() {
let goodbye_message = terminal_teardown_message(
&exit_msg,
diff --git a/zellij-client/src/nested_reannounce.rs b/zellij-client/src/nested_reannounce.rs
new file mode 100644
index 00000000..a151f72f
--- /dev/null
+++ b/zellij-client/src/nested_reannounce.rs
@@ -0,0 +1,63 @@
+use crate::os_input_output::ClientOsApi;
+use std::io::Write;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+use zellij_utils::nested_session::{
+ self, reannounce_check_interval_ms, reannounce_silence_ms, NestedSessionCapability,
+ NestedSessionMessage,
+};
+
+#[derive(Clone)]
+pub struct NestedReannounce {
+ last_heard_from_host: Arc<Mutex<Instant>>,
+ stop: Arc<AtomicBool>,
+}
+
+impl NestedReannounce {
+ pub fn spawn(os_input: Box<dyn ClientOsApi>, session_name: String) -> Self {
+ let handle = NestedReannounce {
+ last_heard_from_host: Arc::new(Mutex::new(Instant::now())),
+ stop: Arc::new(AtomicBool::new(false)),
+ };
+ let last_heard_from_host = handle.last_heard_from_host.clone();
+ let stop = handle.stop.clone();
+ let _ = std::thread::Builder::new()
+ .name("nested_reannounce".to_string())
+ .spawn(move || {
+ let check_interval = Duration::from_millis(reannounce_check_interval_ms());
+ loop {
+ std::thread::sleep(check_interval);
+ if stop.load(Ordering::Relaxed) {
+ break;
+ }
+ let silent_for = {
+ let last = last_heard_from_host.lock().unwrap();
+ last.elapsed()
+ };
+ if silent_for >= Duration::from_millis(reannounce_silence_ms()) {
+ let announce = NestedSessionMessage::Announce {
+ session_name: session_name.clone(),
+ capabilities: vec![NestedSessionCapability::NestedControl],
+ };
+ let mut stdout = os_input.get_stdout_writer();
+ if stdout
+ .write_all(&nested_session::encode_frame(&announce))
+ .is_ok()
+ {
+ let _ = stdout.flush();
+ }
+ }
+ }
+ });
+ handle
+ }
+
+ pub fn note_host_contact(&self) {
+ *self.last_heard_from_host.lock().unwrap() = Instant::now();
+ }
+
+ pub fn stop(&self) {
+ self.stop.store(true, Ordering::Relaxed);
+ }
+}
diff --git a/zellij-client/src/stdin_ansi_parser.rs b/zellij-client/src/stdin_ansi_parser.rs
index d0fb4c32..c70f9e93 100644
--- a/zellij-client/src/stdin_ansi_parser.rs
+++ b/zellij-client/src/stdin_ansi_parser.rs
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use zellij_utils::{
data::HostTerminalThemeMode,
ipc::PixelDimensions,
+ nested_session,
pane_size::SizeInPixels,
vendored::termwiz::input::{InputEvent, InputParser},
};
@@ -187,6 +188,7 @@ pub struct ParseOutput {
/// Residue bytes that were not classified as host replies. These are
/// the bytes the caller should feed to the keyboard parser.
pub residue: Vec<u8>,
+ pub nested_frames: Vec<Vec<u8>>,
/// `true` when the parser still holds buffered partial OSC/CSI
/// bytes that need an idle-flush to release. The caller uses this
/// to schedule a finalize tick even when the current chunk
@@ -264,6 +266,7 @@ pub struct StdinAnsiParser {
/// Same for CSI device-control reports.
partial_csi: Vec<u8>,
partial_paste: Vec<u8>,
+ nested_frame_extractor: nested_session::NestedFrameExtractor,
in_bracketed_paste: bool,
}
@@ -285,6 +288,7 @@ impl StdinAnsiParser {
partial_osc: Vec::new(),
partial_csi: Vec::new(),
partial_paste: Vec::new(),
+ nested_frame_extractor: nested_session::NestedFrameExtractor::new(),
in_bracketed_paste: false,
}
}
@@ -349,6 +353,9 @@ impl StdinAnsiParser {
/// are the bytes the caller should feed to the keyboard parser.
pub fn feed(&mut self, bytes: &[u8]) -> ParseOutput {
let mut out = ParseOutput::default();
+ let (bytes, nested_frames) = self.nested_frame_extractor.extract(bytes);
+ let bytes = &bytes[..];
+ out.nested_frames = nested_frames;
// Collect events first (borrow-splits the InputParser across the
// callback and the post-processing mutations).
let mut events = Vec::new();
@@ -431,13 +438,21 @@ impl StdinAnsiParser {
out.residue = residue;
out.has_partial_state = !self.partial_osc.is_empty()
|| !self.partial_csi.is_empty()
- || !self.partial_paste.is_empty();
+ || !self.partial_paste.is_empty()
+ || !self.nested_frame_extractor.partial_bytes().is_empty();
out
}
pub fn pending_partial(&self) -> PendingPartial {
if !self.partial_paste.is_empty() {
PendingPartial::ReplyInProgress
+ } else if self.partial_csi.is_empty()
+ && self.partial_osc.is_empty()
+ && self.nested_frame_extractor.partial_bytes() == [0x1b]
+ {
+ PendingPartial::LoneEsc
+ } else if !self.nested_frame_extractor.partial_bytes().is_empty() {
+ PendingPartial::ReplyInProgress
} else if self.partial_csi.is_empty() && self.partial_osc.is_empty() {
PendingPartial::None
} else if self.partial_csi.is_empty() && self.partial_osc == [0x1b] {
@@ -450,6 +465,13 @@ impl StdinAnsiParser {
pub fn finalize_lone_esc(&mut self) -> Vec<u8> {
if self.partial_csi.is_empty()
&& self.partial_paste.is_empty()
+ && self.partial_osc.is_empty()
+ && self.nested_frame_extractor.partial_bytes() == [0x1b]
+ {
+ self.nested_frame_extractor.take_partial()
+ } else if self.partial_csi.is_empty()
+ && self.partial_paste.is_empty()
+ && self.nested_frame_extractor.partial_bytes().is_empty()
&& self.partial_osc == [0x1b]
{
std::mem::take(&mut self.partial_osc)
@@ -460,12 +482,17 @@ impl StdinAnsiParser {
pub fn finalize_force(&mut self) -> Vec<u8> {
self.in_bracketed_paste = false;
+ let mut partial_nested_frame = self.nested_frame_extractor.take_partial();
let mut out = Vec::with_capacity(
- self.partial_osc.len() + self.partial_csi.len() + self.partial_paste.len(),
+ self.partial_osc.len()
+ + self.partial_csi.len()
+ + self.partial_paste.len()
+ + partial_nested_frame.len(),
);
out.append(&mut self.partial_osc);
out.append(&mut self.partial_csi);
out.append(&mut self.partial_paste);
+ out.append(&mut partial_nested_frame);
out
}
diff --git a/zellij-client/src/stdin_ansi_parser_tests.rs b/zellij-client/src/stdin_ansi_parser_tests.rs
index 19fd5698..77d796eb 100644
--- a/zellij-client/src/stdin_ansi_parser_tests.rs
+++ b/zellij-client/src/stdin_ansi_parser_tests.rs
@@ -1225,3 +1225,107 @@ fn unsolicited_theme_notification_classifies_without_outstanding_query() {
other => panic!("expected HostTerminalThemeChanged, got {:?}", other),
}
}
+
+fn announce_frame_and_payload() -> (Vec<u8>, Vec<u8>) {
+ use zellij_utils::nested_session::{
+ encode_frame, encode_payload, NestedSessionCapability, NestedSessionMessage,
+ };
+ let message = NestedSessionMessage::Announce {
+ session_name: "guest-session".to_owned(),
+ capabilities: vec![NestedSessionCapability::NestedControl],
+ };
+ (encode_frame(&message), encode_payload(&message))
+}
+
+#[test]
+fn nested_frame_in_one_chunk_is_extracted_with_no_residue() {
+ let (frame, payload) = announce_frame_and_payload();
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(&frame);
+ assert_eq!(out.nested_frames, vec![payload]);
+ assert!(out.residue.is_empty(), "residue: {:?}", out.residue);
+ assert_eq!(p.pending_partial(), PendingPartial::None);
+}
+
+#[test]
+fn nested_frame_split_at_every_boundary_is_extracted() {
+ let (frame, payload) = announce_frame_and_payload();
+ for split_at in 1..frame.len() {
+ let mut p = StdinAnsiParser::new();
+ let first = p.feed(&frame[..split_at]);
+ let second = p.feed(&frame[split_at..]);
+ let mut extracted = first.nested_frames.clone();
+ extracted.extend(second.nested_frames.clone());
+ assert_eq!(extracted, vec![payload.clone()], "split at {}", split_at);
+ assert!(
+ first.residue.is_empty() && second.residue.is_empty(),
+ "split at {}: residue {:?} / {:?}",
+ split_at,
+ first.residue,
+ second.residue
+ );
+ }
+}
+
+#[test]
+fn keystrokes_around_nested_frame_survive_as_residue() {
+ let (frame, payload) = announce_frame_and_payload();
+ let mut chunk = b"abc".to_vec();
+ chunk.extend_from_slice(&frame);
+ chunk.extend_from_slice(b"def");
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(&chunk);
+ assert_eq!(out.nested_frames, vec![payload]);
+ assert_eq!(out.residue, b"abcdef".to_vec());
+}
+
+#[test]
+fn nested_frame_with_garbage_base64_is_stripped_without_payload() {
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(b"\x1bP26661n!!!not-base64!!!\x1b\\");
+ assert!(out.nested_frames.is_empty());
+ assert!(out.residue.is_empty(), "residue: {:?}", out.residue);
+}
+
+#[test]
+fn foreign_dcs_passes_through_untouched() {
+ let foreign_dcs = b"\x1bP1$qm\x1b\\q".to_vec();
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(&foreign_dcs);
+ assert!(out.nested_frames.is_empty());
+ assert_eq!(out.residue, foreign_dcs);
+}
+
+#[test]
+fn lone_esc_claimed_by_frame_prepass_reroutes_on_next_chunk() {
+ let mut p = StdinAnsiParser::new();
+ let first = p.feed(b"\x1b");
+ assert!(first.residue.is_empty());
+ assert!(first.has_partial_state);
+ assert_eq!(p.pending_partial(), PendingPartial::LoneEsc);
+ let second = p.feed(b"abc");
+ assert_eq!(second.residue, b"\x1babc".to_vec());
+ assert_eq!(p.pending_partial(), PendingPartial::None);
+}
+
+#[test]
+fn unterminated_frame_header_prefix_is_released_by_force_drain() {
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(b"\x1bP26");
+ assert!(out.residue.is_empty());
+ assert!(out.has_partial_state);
+ assert_eq!(p.pending_partial(), PendingPartial::ReplyInProgress);
+ assert_eq!(p.finalize_force(), b"\x1bP26".to_vec());
+ assert_eq!(p.pending_partial(), PendingPartial::None);
+}
+
+#[test]
+fn two_nested_frames_in_one_chunk_are_both_extracted() {
+ let (frame, payload) = announce_frame_and_payload();
+ let mut chunk = frame.clone();
+ chunk.extend_from_slice(&frame);
+ let mut p = StdinAnsiParser::new();
+ let out = p.feed(&chunk);
+ assert_eq!(out.nested_frames, vec![payload.clone(), payload]);
+ assert!(out.residue.is_empty());
+}
diff --git a/zellij-client/src/stdin_handler.rs b/zellij-client/src/stdin_handler.rs
index 29b7de7e..681b49b5 100644
--- a/zellij-client/src/stdin_handler.rs
+++ b/zellij-client/src/stdin_handler.rs
@@ -97,7 +97,7 @@ pub(crate) fn stdin_loop(
});
let mut needs_finalization = false;
let mut reply_in_progress_since: Option<Instant> = None;
- loop {
+ 'stdin: loop {
match if needs_finalization {
stdin_rx.recv_timeout(LONE_ESC_FLUSH_INTERVAL)
} else {
@@ -132,6 +132,10 @@ pub(crate) fn stdin_loop(
let _ = send_input_instructions
.send(InputInstruction::DesktopNotificationResponse(payload));
}
+ for payload_bytes in parse_output.nested_frames {
+ let _ = send_input_instructions
+ .send(InputInstruction::NestedSessionFrameFromHost(payload_bytes));
+ }
let residue = parse_output.residue;
if residue.is_empty() {
schedule_finalization(
@@ -153,13 +157,16 @@ pub(crate) fn stdin_loop(
// continuation completes the sequence.
match kitty_parser.feed(&residue) {
KittyParseOutcome::Complete(key_with_modifier) => {
- send_input_instructions
+ if send_input_instructions
.send(InputInstruction::KeyWithModifierEvent(
key_with_modifier,
current_buffer.drain(..).collect(),
true,
))
- .unwrap();
+ .is_err()
+ {
+ break 'stdin;
+ }
schedule_finalization(
&stdin_ansi_parser,
false,
@@ -196,9 +203,12 @@ pub(crate) fn stdin_loop(
for (input_event, consumed) in events.into_iter() {
let take = consumed.min(current_buffer.len());
let raw_bytes: Vec<u8> = current_buffer.drain(..take).collect();
- send_input_instructions
+ if send_input_instructions
.send(InputInstruction::KeyEvent(input_event, raw_bytes))
- .unwrap();
+ .is_err()
+ {
+ break 'stdin;
+ }
}
schedule_finalization(
@@ -317,12 +327,22 @@ fn build_startup_query_string() -> String {
// <ESC>]11;?<ESC>\ => get background color
// <ESC>]10;?<ESC>\ => get foreground color
// <ESC>[?2026$p => get synchronised output mode
- String::from("\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p")
+ format!(
+ "{}\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p",
+ PIXEL_SIZE_QUERY
+ )
}
+pub(crate) const PIXEL_SIZE_QUERY: &str = "\u{1b}[14t\u{1b}[16t";
+
#[cfg(test)]
mod tests {
- use super::build_startup_query_string;
+ use super::{build_startup_query_string, PIXEL_SIZE_QUERY};
+
+ #[test]
+ fn pixel_size_query_probes_text_area_and_character_cell() {
+ assert_eq!(PIXEL_SIZE_QUERY, "\u{1b}[14t\u{1b}[16t");
+ }
#[test]
fn startup_query_has_no_palette_register_loop() {
diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs
index f6d3fade..9bb864c8 100644
--- a/zellij-client/src/unit/terminal_loop_tests.rs
+++ b/zellij-client/src/unit/terminal_loop_tests.rs
@@ -351,7 +351,9 @@ async fn test_stdin_forwarded_to_terminal_websocket() {
let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
let loop_handle =
- tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
let test_data = b"hello from stdin\n".to_vec();
stdin_tx.send(test_data.clone()).unwrap();
@@ -420,7 +422,9 @@ async fn test_terminal_output_written_to_stdout() {
let os_input = Box::new(os_input);
let loop_handle =
- tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
let test_output = "Hello from terminal";
server
@@ -490,7 +494,9 @@ async fn test_resize_signal_sends_control_message() {
let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
let loop_handle =
- tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
tokio::time::sleep(Duration::from_millis(200)).await;
@@ -576,7 +582,9 @@ async fn test_quit_signal_exits_loop() {
let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
let loop_handle =
- tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
signal_tx.send(SignalEvent::Quit).unwrap();
@@ -629,7 +637,9 @@ async fn test_websocket_close_exits_loop() {
let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
let loop_handle =
- tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
server
.terminal_to_client_tx
@@ -687,7 +697,9 @@ async fn test_control_message_handling() {
let os_input = Box::new(os_input);
let loop_handle =
- tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
tokio::time::sleep(Duration::from_millis(200)).await;
@@ -746,3 +758,148 @@ async fn test_control_message_handling() {
.expect("Loop didn't exit")
.unwrap();
}
+
+async fn connect_mock_ws(port: u16) -> WebSocketConnections {
+ let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port);
+ let control_url = format!("ws://127.0.0.1:{}/ws/control", port);
+ let terminal_tcp = TcpStream::connect(format!("127.0.0.1:{}", port))
+ .await
+ .unwrap();
+ let (terminal_ws, _) = tokio_tungstenite::client_async_with_config(
+ &terminal_url,
+ MaybeTls::Plain(terminal_tcp),
+ None,
+ )
+ .await
+ .unwrap();
+ let control_tcp = TcpStream::connect(format!("127.0.0.1:{}", port))
+ .await
+ .unwrap();
+ let (control_ws, _) = tokio_tungstenite::client_async_with_config(
+ &control_url,
+ MaybeTls::Plain(control_tcp),
+ None,
+ )
+ .await
+ .unwrap();
+ WebSocketConnections {
+ terminal_ws,
+ control_ws,
+ web_client_id: "test-nested".to_string(),
+ }
+}
+
+#[tokio::test]
+#[serial]
+async fn test_nested_ping_frame_is_answered_locally_and_stripped_from_forwarded_stdin() {
+ let (port, server, _server_handle) = mock_ws_server::MockWsServer::start().await;
+ let connections = connect_mock_ws(port).await;
+
+ let (stdin_tx, stdin_rx) = mpsc::unbounded_channel();
+ let (_signal_tx, signal_rx) = mpsc::unbounded_channel();
+ let os_input = TestClientOsApi::new(stdin_rx, signal_rx);
+ let stdout_buffer = os_input.stdout_buffer.clone();
+ let os_input = Box::new(os_input);
+
+ let loop_handle =
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
+
+ let mut chunk = b"abc".to_vec();
+ chunk.extend_from_slice(&zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::Ping,
+ ));
+ chunk.extend_from_slice(b"def");
+ stdin_tx.send(chunk).unwrap();
+
+ tokio::time::sleep(Duration::from_millis(200)).await;
+ let received = tokio::time::timeout(
+ Duration::from_secs(1),
+ server.client_to_terminal_rx.lock().unwrap().recv(),
+ )
+ .await
+ .expect("Timeout")
+ .expect("No message");
+ match received {
+ Message::Binary(data) => assert_eq!(data, b"abcdef".to_vec()),
+ _ => panic!("Expected Binary message, got: {:?}", received),
+ }
+
+ let pong_frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::Pong,
+ );
+ let stdout_bytes = stdout_buffer.lock().unwrap().clone();
+ assert!(
+ stdout_bytes
+ .windows(pong_frame.len())
+ .any(|window| window == &pong_frame[..]),
+ "expected a pong frame on stdout, got: {:?}",
+ stdout_bytes
+ );
+
+ drop(stdin_tx);
+ let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle)
+ .await
+ .expect("Loop didn't exit")
+ .unwrap();
+}
+
+#[tokio::test]
+#[serial]
+async fn test_nested_announce_ack_is_relayed_over_control_websocket() {
+ let (port, server, _server_handle) = mock_ws_server::MockWsServer::start().await;
+ let connections = connect_mock_ws(port).await;
+
+ let (stdin_tx, stdin_rx) = mpsc::unbounded_channel();
+ let (_signal_tx, signal_rx) = mpsc::unbounded_channel();
+ let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
+
+ let loop_handle =
+ tokio::spawn(
+ async move { run_remote_client_terminal_loop(os_input, connections, None).await },
+ );
+
+ let announce_ack = zellij_utils::nested_session::NestedSessionMessage::AnnounceAck {
+ ancestry: vec!["outer-host".to_owned()],
+ capabilities: vec![zellij_utils::nested_session::NestedSessionCapability::NestedControl],
+ descend_keys: vec![],
+ };
+ stdin_tx
+ .send(zellij_utils::nested_session::encode_frame(&announce_ack))
+ .unwrap();
+
+ tokio::time::sleep(Duration::from_millis(200)).await;
+ let mut relayed_payload = None;
+ loop {
+ let received = tokio::time::timeout(
+ Duration::from_secs(1),
+ server.client_to_control_rx.lock().unwrap().recv(),
+ )
+ .await
+ .expect("Timeout")
+ .expect("No message");
+ if let Message::Text(text) = received {
+ let control_msg: WebClientToWebServerControlMessage =
+ serde_json::from_str(&text).unwrap();
+ if let WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost {
+ payload_bytes,
+ } = control_msg.payload
+ {
+ assert_eq!(control_msg.web_client_id, "test-nested");
+ relayed_payload = Some(payload_bytes);
+ break;
+ }
+ }
+ }
+ assert_eq!(
+ zellij_utils::nested_session::decode_payload(&relayed_payload.unwrap()),
+ Some(announce_ack)
+ );
+
+ drop(stdin_tx);
+ let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle)
+ .await
+ .expect("Loop didn't exit")
+ .unwrap();
+}
diff --git a/zellij-client/src/web_client/control_message.rs b/zellij-client/src/web_client/control_message.rs
index 8ab333f9..7771d3e9 100644
--- a/zellij-client/src/web_client/control_message.rs
+++ b/zellij-client/src/web_client/control_message.rs
@@ -16,6 +16,7 @@ pub enum WebClientToWebServerControlMessagePayload {
TerminalSizeSettled(Size),
TerminalMetrics(TerminalMetricsPayload),
SoftKeyboardVisibilityChanged { visible: bool },
+ NestedSessionFrameFromHost { payload_bytes: Vec<u8> },
}
#[derive(Serialize, Deserialize, Debug, Clone)]
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index 9daa8fa8..c66a1286 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -255,6 +255,7 @@ pub fn zellij_server_listener(
// Subscribe-only messages — not relevant for web clients
Some(ServerToClientMsg::PaneRenderUpdate { .. }) => {},
Some(ServerToClientMsg::SubscribedPaneClosed { .. }) => {},
+ Some(ServerToClientMsg::EmitNestedSessionFrame { .. }) => {},
Some(ServerToClientMsg::ForwardQueryToHost { token, .. }) => {
// Reply immediately with empty reply_bytes.
// This is the existing convention that signals
diff --git a/zellij-client/src/web_client/websocket_handlers.rs b/zellij-client/src/web_client/websocket_handlers.rs
index 11c81a9e..cbf6eefd 100644
--- a/zellij-client/src/web_client/websocket_handlers.rs
+++ b/zellij-client/src/web_client/websocket_handlers.rs
@@ -99,6 +99,9 @@ async fn handle_ws_control(
WebClientToWebServerControlMessagePayload::SoftKeyboardVisibilityChanged {
visible,
} => ClientToServerMsg::SoftKeyboardVisibilityChanged { visible },
+ WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost {
+ payload_bytes,
+ } => ClientToServerMsg::NestedSessionFrameFromHost { payload_bytes },
};
let _ = client_connection.send_to_server(client_msg);
diff --git a/zellij-integration-tests/src/client_screen.rs b/zellij-integration-tests/src/client_screen.rs
index 479c9b56..7bdbc9be 100644
--- a/zellij-integration-tests/src/client_screen.rs
+++ b/zellij-integration-tests/src/client_screen.rs
@@ -4,6 +4,7 @@ use std::rc::Rc;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Instant;
+use zellij_server::panes::terminal_character::AnsiCode;
use zellij_server::panes::{LinkHandler, TerminalPane};
use zellij_utils::data::{Palette, Style};
use zellij_utils::pane_size::{Dimension, PaneGeom, Size, SizeInPixels};
@@ -18,6 +19,7 @@ struct ReceivedBytes {
struct ReceivedBytesWithChangeSignal {
received_bytes: Mutex<ReceivedBytes>,
change_signal: Condvar,
+ stdout_tap: Mutex<Option<crossbeam::channel::Sender<Vec<u8>>>>,
}
#[derive(Clone)]
@@ -40,12 +42,47 @@ impl ClientScreen {
})
}
+ pub fn set_stdout_tap(&self, sender: crossbeam::channel::Sender<Vec<u8>>) {
+ *self.inner.stdout_tap.lock().unwrap() = Some(sender);
+ }
+
pub fn snapshot(&self) -> GridSnapshot {
let size = *self.size.lock().unwrap();
let bytes = self.inner.received_bytes.lock().unwrap().bytes.clone();
render_bytes(&bytes, size)
}
+ pub fn wait_until_raw_output(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> {
+ let deadline = Instant::now() + crate::default_timeout();
+ let mut received_bytes = self.inner.received_bytes.lock().unwrap();
+ loop {
+ if predicate(&received_bytes.bytes) {
+ return received_bytes.bytes.clone();
+ }
+ let now = Instant::now();
+ if now >= deadline {
+ panic!(
+ "timed out waiting for: {}\n=== (received {} stdout bytes, generation {}) ===\n=== zellij log tail ({}) ===\n{}",
+ what,
+ received_bytes.bytes.len(),
+ received_bytes.generation,
+ crate::test_env::log_file_path().display(),
+ crate::test_env::log_tail(40),
+ );
+ }
+ let last_generation = received_bytes.generation;
+ let (guard, _) = self
+ .inner
+ .change_signal
+ .wait_timeout(received_bytes, deadline - now)
+ .unwrap();
+ received_bytes = guard;
+ if received_bytes.generation == last_generation {
+ continue;
+ }
+ }
+ }
+
pub fn wait_until(
&self,
what: &str,
@@ -95,6 +132,9 @@ impl std::io::Write for ClientScreenWriter {
let mut received_bytes = self.inner.received_bytes.lock().unwrap();
received_bytes.bytes.extend_from_slice(buf);
received_bytes.generation += 1;
+ if let Some(stdout_tap) = self.inner.stdout_tap.lock().unwrap().as_ref() {
+ let _ = stdout_tap.send(buf.to_vec());
+ }
self.inner.change_signal.notify_all();
Ok(buf.len())
}
@@ -129,16 +169,86 @@ impl CoordBuilder {
}
}
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct CellStyle {
+ pub dim: bool,
+ pub italic: bool,
+ pub bold: bool,
+}
+
#[derive(Clone, Debug)]
pub struct GridSnapshot {
pub text: String,
pub cursor: Option<CursorPosition>,
+ pub styles: Vec<Vec<CellStyle>>,
}
impl GridSnapshot {
pub fn contains(&self, needle: &str) -> bool {
self.text.contains(needle)
}
+ pub fn cell_style(&self, x: usize, y: usize) -> CellStyle {
+ self.styles
+ .get(y)
+ .and_then(|row| row.get(x))
+ .copied()
+ .unwrap_or_default()
+ }
+ pub fn char_is_dim(&self, x: usize, y: usize) -> bool {
+ self.cell_style(x, y).dim
+ }
+ pub fn char_is_italic(&self, x: usize, y: usize) -> bool {
+ self.cell_style(x, y).italic
+ }
+ pub fn char_is_bold(&self, x: usize, y: usize) -> bool {
+ self.cell_style(x, y).bold
+ }
+ pub fn row_has_italic(&self, y: usize) -> bool {
+ self.styles
+ .get(y)
+ .map_or(false, |row| row.iter().any(|cell| cell.italic))
+ }
+ pub fn row_has_bold(&self, y: usize) -> bool {
+ self.styles
+ .get(y)
+ .map_or(false, |row| row.iter().any(|cell| cell.bold))
+ }
+ pub fn row_count(&self) -> usize {
+ self.styles.len()
+ }
+ pub fn row_of_line(&self, needle: &str) -> Option<usize> {
+ self.text.lines().position(|line| line.contains(needle))
+ }
+ pub fn region_has_dim(
+ &self,
+ x_range: std::ops::Range<usize>,
+ y_range: std::ops::Range<usize>,
+ ) -> bool {
+ for y in y_range {
+ for x in x_range.clone() {
+ if self.char_is_dim(x, y) {
+ return true;
+ }
+ }
+ }
+ false
+ }
+ pub fn line_has_dim(&self, needle: &str) -> bool {
+ match self.row_of_line(needle) {
+ Some(y) => self
+ .styles
+ .get(y)
+ .map_or(false, |row| row.iter().any(|cell| cell.dim)),
+ None => false,
+ }
+ }
+ pub fn char_dim_of(&self, needle: &str) -> Option<bool> {
+ self.text.lines().enumerate().find_map(|(y, line)| {
+ line.find(needle)
+ .map(|byte_index| line[..byte_index].chars().count())
+ .map(|x| self.char_is_dim(x, y))
+ })
+ }
pub fn cursor_is_at(&self, coord: Coord) -> bool {
self.cursor
== Some(CursorPosition {
@@ -180,9 +290,17 @@ fn render_bytes(bytes: &[u8], win_size: Size) -> GridSnapshot {
}
});
let mut text = String::new();
+ let mut styles: Vec<Vec<CellStyle>> = Vec::new();
let output_lines = terminal_pane.read_buffer_as_lines();
for (line_index, line) in output_lines.iter().enumerate() {
+ let mut style_row: Vec<CellStyle> = Vec::with_capacity(line.len());
for (character_index, terminal_character) in line.iter().enumerate() {
+ let character_style = &terminal_character.styles;
+ style_row.push(CellStyle {
+ dim: matches!(character_style.dim, Some(AnsiCode::On)),
+ italic: matches!(character_style.italic, Some(AnsiCode::On)),
+ bold: matches!(character_style.bold, Some(AnsiCode::On)),
+ });
let character_position = CursorPosition {
x: character_index,
y: line_index,
@@ -193,11 +311,16 @@ fn render_bytes(bytes: &[u8], win_size: Size) -> GridSnapshot {
}
text.push(terminal_character.character);
}
+ styles.push(style_row);
if line_index != output_lines.len() - 1 {
text.push('\n');
}
}
- GridSnapshot { text, cursor }
+ GridSnapshot {
+ text,
+ cursor,
+ styles,
+ }
}
fn build_terminal_pane(win_size: Size) -> TerminalPane {
diff --git a/zellij-integration-tests/src/fake_client_os_api.rs b/zellij-integration-tests/src/fake_client_os_api.rs
index d341758a..8dcd1793 100644
--- a/zellij-integration-tests/src/fake_client_os_api.rs
+++ b/zellij-integration-tests/src/fake_client_os_api.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@@ -23,6 +24,7 @@ pub struct FakeClientOsApi {
receive_instructions_from_server: Arc<Mutex<Option<IpcReceiverWithContext<ServerToClientMsg>>>>,
session_name: Arc<Mutex<Option<String>>>,
server_spawner: Arc<Mutex<Option<ServerSpawner>>>,
+ env: Arc<Mutex<HashMap<String, String>>>,
}
impl Clone for FakeClientOsApi {
@@ -36,6 +38,7 @@ impl Clone for FakeClientOsApi {
receive_instructions_from_server: self.receive_instructions_from_server.clone(),
session_name: self.session_name.clone(),
server_spawner: self.server_spawner.clone(),
+ env: self.env.clone(),
}
}
}
@@ -58,6 +61,14 @@ impl FakeClientOsApi {
initial_size: Size,
server_spawner: Option<ServerSpawner>,
) -> (Self, FakeClientHandle) {
+ Self::new_with_env(initial_size, server_spawner, HashMap::new())
+ }
+
+ pub fn new_with_env(
+ initial_size: Size,
+ server_spawner: Option<ServerSpawner>,
+ env: HashMap<String, String>,
+ ) -> (Self, FakeClientHandle) {
let size = Arc::new(Mutex::new(initial_size));
let client_screen = ClientScreen::new(size.clone());
let (stdin_tx, stdin_rx) = crossbeam::channel::unbounded();
@@ -71,6 +82,7 @@ impl FakeClientOsApi {
receive_instructions_from_server: Arc::new(Mutex::new(None)),
session_name: Arc::new(Mutex::new(None)),
server_spawner: Arc::new(Mutex::new(server_spawner)),
+ env: Arc::new(Mutex::new(env)),
};
let fake_client_handle = FakeClientHandle {
client_screen,
@@ -168,13 +180,19 @@ impl ClientOsApi for FakeClientOsApi {
default_palette()
}
fn enable_mouse(&self) -> anyhow::Result<()> {
+ use std::io::Write;
+ const ENABLE_MOUSE_SUPPORT: &str =
+ "\u{1b}[?1000h\u{1b}[?1002h\u{1b}[?1003h\u{1b}[?1015h\u{1b}[?1006h";
+ let mut writer = self.client_screen.writer();
+ writer.write_all(ENABLE_MOUSE_SUPPORT.as_bytes())?;
+ writer.flush()?;
Ok(())
}
fn disable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
- fn env_variable(&self, _name: &str) -> Option<String> {
- None
+ fn env_variable(&self, name: &str) -> Option<String> {
+ self.env.lock().unwrap().get(name).cloned()
}
fn should_install_panic_hook(&self) -> bool {
false
diff --git a/zellij-integration-tests/src/fake_pty.rs b/zellij-integration-tests/src/fake_pty.rs
index 9191c077..90aa03e1 100644
--- a/zellij-integration-tests/src/fake_pty.rs
+++ b/zellij-integration-tests/src/fake_pty.rs
@@ -1,6 +1,6 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
-use std::time::Instant;
+use std::time::{Duration, Instant};
use async_trait::async_trait;
use zellij_server::os_input_output::AsyncReader;
@@ -19,6 +19,7 @@ pub(crate) struct FakePtyState {
pub quit_cb: Option<QuitCb>,
pub exited: bool,
pub echo: bool,
+ pub stdin_tap: Option<crossbeam::channel::Sender<Vec<u8>>>,
}
impl FakePtyState {
@@ -116,6 +117,7 @@ impl SharedPtys {
quit_cb,
exited: false,
echo: true,
+ stdin_tap: None,
},
);
fake_pty_registry.spawn_queue.push_back(terminal_id);
@@ -172,6 +174,18 @@ impl SharedPtys {
});
}
+ pub(crate) fn set_stdin_tap(
+ &self,
+ terminal_id: u32,
+ sender: crossbeam::channel::Sender<Vec<u8>>,
+ ) {
+ self.mutate(|fake_pty_registry| {
+ if let Some(fake_pty_state) = fake_pty_registry.fake_pty_states.get_mut(&terminal_id) {
+ fake_pty_state.stdin_tap = Some(sender);
+ }
+ });
+ }
+
pub(crate) fn write_output(&self, terminal_id: u32, bytes: &[u8]) {
self.read(|fake_pty_registry| {
if let Some(fake_pty_state) = fake_pty_registry.fake_pty_states.get(&terminal_id) {
@@ -192,6 +206,9 @@ impl SharedPtys {
let _ = output_tx.send(bytes.to_vec());
}
}
+ if let Some(stdin_tap) = fake_pty_state.stdin_tap.as_ref() {
+ let _ = stdin_tap.send(bytes.to_vec());
+ }
true
},
None => false,
@@ -228,6 +245,32 @@ impl SharedPtys {
fake_pty_registry = guard;
}
}
+
+ pub(crate) fn wait_for_change(
+ &self,
+ terminal_id: u32,
+ last_seen: (u16, u16),
+ ) -> Option<(u16, u16)> {
+ let mut fake_pty_registry = self.lock_registry();
+ loop {
+ match fake_pty_registry.fake_pty_states.get(&terminal_id) {
+ None => return None,
+ Some(fake_pty_state) => {
+ if let Some((cols, rows)) = fake_pty_state.size {
+ if (cols, rows) != last_seen {
+ return Some((cols, rows));
+ }
+ }
+ },
+ }
+ let (guard, _) = self
+ .inner
+ .change_signal
+ .wait_timeout(fake_pty_registry, Duration::from_millis(200))
+ .unwrap();
+ fake_pty_registry = guard;
+ }
+ }
}
struct FakeAsyncReader {
@@ -302,6 +345,16 @@ impl FakePtyHandle {
})
}
+ pub fn try_output(&self, bytes: &[u8]) {
+ self.shared_ptys.write_output(self.terminal_id, bytes);
+ }
+
+ pub fn tap_stdin(&self) -> crossbeam::channel::Receiver<Vec<u8>> {
+ let (sender, receiver) = crossbeam::channel::unbounded();
+ self.shared_ptys.set_stdin_tap(self.terminal_id, sender);
+ receiver
+ }
+
pub fn exit(&self, exit_status: Option<i32>) {
self.shared_ptys
.exit_terminal(self.terminal_id, exit_status);
@@ -362,4 +415,9 @@ impl FakePtyHandle {
.filter(|(cols, rows)| predicate(*cols, *rows))
})
}
+
+ pub fn wait_for_size_change(&self, last_seen: (u16, u16)) -> Option<(u16, u16)> {
+ self.shared_ptys
+ .wait_for_change(self.terminal_id, last_seen)
+ }
}
diff --git a/zellij-integration-tests/src/lib.rs b/zellij-integration-tests/src/lib.rs
index 18f652fb..4a278d0e 100644
--- a/zellij-integration-tests/src/lib.rs
+++ b/zellij-integration-tests/src/lib.rs
@@ -6,16 +6,24 @@ pub mod fake_pty;
pub mod fake_server_os_api;
pub mod harness;
pub mod keys;
+pub mod nested;
pub mod runner;
pub mod test_env;
-pub use client_screen::{col, ClientScreen, Coord, CoordBuilder, CursorPosition, GridSnapshot};
+pub use client_screen::{
+ col, CellStyle, ClientScreen, Coord, CoordBuilder, CursorPosition, GridSnapshot,
+};
pub use fake_pty::FakePtyHandle;
pub use harness::{
claim_first_terminal_and_wait_for_prompt, split_down_and_wait_for_prompt,
split_right_and_wait_for_prompt, start_zellij, PROMPT, TERMINAL_SIZE,
};
-pub use runner::{normalized, TestClient, TestRunner, TestSession};
+pub use nested::{
+ composite_contains_settled_guest_grid, guest_ascended_bar_settled, host_descended_bar_settled,
+ host_descended_bar_with_ascend_keys_settled, wait_for_settled_composite,
+ NestedDepthThreeHarness, NestedHarness,
+};
+pub use runner::{assert_same_rendered_grid, normalized, TestClient, TestRunner, TestSession};
pub use zellij_utils::data::LayoutInfo;
pub use zellij_utils::pane_size::Size;
diff --git a/zellij-integration-tests/src/nested.rs b/zellij-integration-tests/src/nested.rs
new file mode 100644
index 00000000..3a363fbe
--- /dev/null
+++ b/zellij-integration-tests/src/nested.rs
@@ -0,0 +1,697 @@
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Condvar, Mutex};
+use std::thread::{self, JoinHandle};
+use std::time::{Duration, Instant};
+
+use crossbeam::channel::{Receiver, Sender};
+use zellij_utils::nested_session::{decode_payload, NestedFrameExtractor, NestedSessionMessage};
+
+use crate::client_screen::GridSnapshot;
+use crate::fake_pty::FakePtyHandle;
+use crate::runner::{GuestResizer, TestRunner, TestSession};
+use crate::Size;
+
+pub fn host_chrome_undimmed_after_guest_exit(host_grid: &GridSnapshot) -> bool {
+ !host_grid.line_has_dim("Pane #2") && host_grid.char_dim_of("test-").map_or(true, |dim| !dim)
+}
+
+pub fn composite_contains_settled_guest_grid(
+ host_grid: &GridSnapshot,
+ guest_grid: &GridSnapshot,
+) -> bool {
+ let host_lines: Vec<Vec<char>> = host_grid
+ .text
+ .lines()
+ .map(|line| line.chars().collect())
+ .collect();
+ let guest_lines: Vec<Vec<char>> = guest_grid
+ .text
+ .lines()
+ .map(|line| line.chars().collect())
+ .collect();
+ let guest_rows = guest_lines.len();
+ let guest_cols = guest_lines.iter().map(|line| line.len()).max().unwrap_or(0);
+ let host_rows = host_lines.len();
+ let host_cols = host_lines.iter().map(|line| line.len()).max().unwrap_or(0);
+ if guest_rows == 0 || guest_cols == 0 || guest_rows > host_rows || guest_cols > host_cols {
+ return false;
+ }
+ for row_offset in 0..=(host_rows - guest_rows) {
+ 'next_column_offset: for col_offset in 0..=(host_cols - guest_cols) {
+ for (guest_row, guest_line) in guest_lines.iter().enumerate() {
+ let host_line = &host_lines[row_offset + guest_row];
+ for (guest_col, guest_char) in guest_line.iter().enumerate() {
+ let guest_cell_is_cursor = guest_grid.cursor.map_or(false, |cursor| {
+ cursor.x == guest_col && cursor.y == guest_row
+ });
+ if guest_cell_is_cursor {
+ continue;
+ }
+ let host_char = host_line
+ .get(col_offset + guest_col)
+ .copied()
+ .unwrap_or(' ');
+ if host_char != *guest_char {
+ continue 'next_column_offset;
+ }
+ }
+ }
+ return true;
+ }
+ }
+ false
+}
+
+pub fn host_descended_bar_settled(host_grid: &GridSnapshot) -> bool {
+ host_grid
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains("Ascend:"))
+}
+
+pub fn host_descended_bar_with_ascend_keys_settled(host_grid: &GridSnapshot) -> bool {
+ host_grid.lines().last().map_or(false, |last_line| {
+ last_line.contains("Ascend:") && !last_line.contains("<unbound>")
+ })
+}
+
+pub fn guest_ascended_bar_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_grid
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains("Descend:"))
+}
+
+pub fn wait_for_settled_composite(
+ host: &TestSession,
+ guest: &TestSession,
+ what: &str,
+ guest_settled: impl Fn(&GridSnapshot) -> bool,
+ host_settled: impl Fn(&GridSnapshot) -> bool,
+) -> GridSnapshot {
+ host.wait_until(what, |host_grid| {
+ let guest_grid = guest.snapshot();
+ guest_settled(&guest_grid)
+ && host_settled(host_grid)
+ && composite_contains_settled_guest_grid(host_grid, &guest_grid)
+ })
+}
+
+pub const GUEST_PING_INTERVAL_MS: u64 = 100;
+pub const GUEST_PONG_TIMEOUT_MS: u64 = 4000;
+
+#[derive(Clone, Default)]
+pub struct FrameLog {
+ inner: Arc<FrameLogInner>,
+}
+
+#[derive(Default)]
+struct FrameLogInner {
+ messages: Mutex<Vec<NestedSessionMessage>>,
+ signal: Condvar,
+}
+
+impl FrameLog {
+ fn push(&self, message: NestedSessionMessage) {
+ self.inner.messages.lock().unwrap().push(message);
+ self.inner.signal.notify_all();
+ }
+
+ pub fn count(&self, mut matcher: impl FnMut(&NestedSessionMessage) -> bool) -> usize {
+ self.inner
+ .messages
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|message| matcher(message))
+ .count()
+ }
+
+ pub fn mark(&self) -> usize {
+ self.inner.messages.lock().unwrap().len()
+ }
+
+ pub fn wait_for(&self, what: &str, matcher: impl FnMut(&NestedSessionMessage) -> bool) {
+ self.wait_for_after(0, what, matcher);
+ }
+
+ pub fn wait_for_after(
+ &self,
+ since: usize,
+ what: &str,
+ mut matcher: impl FnMut(&NestedSessionMessage) -> bool,
+ ) {
+ let deadline = Instant::now() + crate::default_timeout();
+ let mut messages = self.inner.messages.lock().unwrap();
+ loop {
+ if messages.iter().skip(since).any(|message| matcher(message)) {
+ return;
+ }
+ let now = Instant::now();
+ if now >= deadline {
+ panic!(
+ "timed out waiting for nested frame: {} (after frame index {})\nframes seen: {:?}\n=== zellij log tail ({}) ===\n{}",
+ what,
+ since,
+ *messages,
+ crate::test_env::log_file_path().display(),
+ crate::test_env::log_tail(40),
+ );
+ }
+ let (guard, _) = self
+ .inner
+ .signal
+ .wait_timeout(messages, deadline - now)
+ .unwrap();
+ messages = guard;
+ }
+ }
+}
+
+pub struct NestedHarness {
+ pub host: TestSession,
+ pub guest: TestSession,
+ pub host_pane: FakePtyHandle,
+ guest_to_host: FrameLog,
+ host_to_guest: FrameLog,
+ frozen: Arc<AtomicBool>,
+ _forwarders: Vec<JoinHandle<()>>,
+}
+
+fn set_nested_timing_env() {
+ std::env::set_var(
+ "ZELLIJ_NESTED_PING_INTERVAL_MS",
+ GUEST_PING_INTERVAL_MS.to_string(),
+ );
+ std::env::set_var(
+ "ZELLIJ_NESTED_PONG_TIMEOUT_MS",
+ GUEST_PONG_TIMEOUT_MS.to_string(),
+ );
+}
+
+struct GuestBridge {
+ guest: TestSession,
+ guest_to_host: FrameLog,
+ host_to_guest: FrameLog,
+ forwarders: Vec<JoinHandle<()>>,
+}
+
+fn bridge_guest_into_pane_for_host(
+ host_pane: &FakePtyHandle,
+ host_session_name: &str,
+ frozen: Arc<AtomicBool>,
+) -> GuestBridge {
+ bridge_guest_into_pane_for_host_with_config(host_pane, host_session_name, frozen, "")
+}
+
+fn bridge_guest_into_pane_for_host_with_config(
+ host_pane: &FakePtyHandle,
+ host_session_name: &str,
+ frozen: Arc<AtomicBool>,
+ guest_config_kdl: &str,
+) -> GuestBridge {
+ host_pane.disable_echo();
+ let (guest_cols, guest_rows) = host_pane
+ .wait_for_size("host guest-pane to be sized", |cols, rows| {
+ cols > 0 && rows > 0
+ });
+
+ let (guest_stdout_tx, guest_stdout_rx) = crossbeam::channel::unbounded();
+ let guest = TestRunner::new(Size {
+ cols: guest_cols as usize,
+ rows: guest_rows as usize,
+ })
+ .as_nested_guest(host_session_name)
+ .with_config(guest_config_kdl)
+ .with_stdout_tap(guest_stdout_tx)
+ .skip_concurrency_slot()
+ .start();
+
+ let host_stdin_rx = host_pane.tap_stdin();
+ let guest_stdin_tx = guest.stdin_sender();
+ let guest_resizer = guest.resize_sender();
+
+ let guest_to_host = FrameLog::default();
+ let host_to_guest = FrameLog::default();
+
+ let forwarders = vec![
+ spawn_guest_to_host(
+ guest_stdout_rx,
+ host_pane.clone(),
+ frozen,
+ guest_to_host.clone(),
+ ),
+ spawn_host_to_guest(host_stdin_rx, guest_stdin_tx, host_to_guest.clone()),
+ spawn_pane_resize_to_guest(host_pane.clone(), guest_resizer, guest_cols, guest_rows),
+ ];
+
+ GuestBridge {
+ guest,
+ guest_to_host,
+ host_to_guest,
+ forwarders,
+ }
+}
+
+impl NestedHarness {
+ pub fn start(host_size: Size) -> Self {
+ Self::start_with_host_config(host_size, "")
+ }
+
+ pub fn start_with_host_config(host_size: Size, host_config_kdl: &str) -> Self {
+ Self::start_with_host_and_guest_config(host_size, host_config_kdl, "")
+ }
+
+ pub fn start_with_host_and_guest_config(
+ host_size: Size,
+ host_config_kdl: &str,
+ guest_config_kdl: &str,
+ ) -> Self {
+ set_nested_timing_env();
+
+ let host = TestRunner::new(host_size)
+ .with_config(host_config_kdl)
+ .start();
+ host.wait_for_app_load();
+ let host_pane = host.expect_pty_spawn();
+ let host_session_name = host.session_name().to_string();
+
+ let frozen = Arc::new(AtomicBool::new(false));
+ let bridge = bridge_guest_into_pane_for_host_with_config(
+ &host_pane,
+ &host_session_name,
+ frozen.clone(),
+ guest_config_kdl,
+ );
+
+ NestedHarness {
+ host,
+ guest: bridge.guest,
+ host_pane,
+ guest_to_host: bridge.guest_to_host,
+ host_to_guest: bridge.host_to_guest,
+ frozen,
+ _forwarders: bridge.forwarders,
+ }
+ }
+
+ pub fn guest_to_host(&self) -> &FrameLog {
+ &self.guest_to_host
+ }
+
+ pub fn host_to_guest(&self) -> &FrameLog {
+ &self.host_to_guest
+ }
+
+ pub fn mark_host_to_guest(&self) -> usize {
+ self.host_to_guest.mark()
+ }
+
+ pub fn mark_guest_to_host(&self) -> usize {
+ self.guest_to_host.mark()
+ }
+
+ pub fn wait_for_guest_to_announce(&self) {
+ self.guest_to_host.wait_for(
+ "the inner (guest) session to announce itself to the outer (host) session it is running inside",
+ |message| matches!(message, NestedSessionMessage::Announce { .. }),
+ );
+ }
+
+ pub fn wait_for_host_to_acknowledge_guest(&self) {
+ self.host_to_guest.wait_for(
+ "the outer (host) session to acknowledge the guest session running inside one of its panes",
+ |message| matches!(message, NestedSessionMessage::AnnounceAck { .. }),
+ );
+ }
+
+ pub fn wait_for_host_to_ping_guest(&self) {
+ self.host_to_guest.wait_for(
+ "the outer (host) session to ping the guest running inside its pane to check it is still alive",
+ |message| matches!(message, NestedSessionMessage::Ping),
+ );
+ }
+
+ pub fn wait_for_guest_to_reply_to_ping(&self) {
+ self.guest_to_host.wait_for(
+ "the inner (guest) session to answer the host's ping and prove it is still alive",
+ |message| matches!(message, NestedSessionMessage::Pong),
+ );
+ }
+
+ pub fn wait_for_host_to_release_guest_focus(&self) {
+ self.wait_for_host_to_reclaim_focus_after_guest_exit();
+ }
+
+ pub fn wait_for_host_to_reclaim_focus_after_guest_exit(&self) {
+ self.host.wait_until(
+ "the outer (host) session to notice the guest is gone and reclaim focus (undim its chrome)",
+ host_chrome_undimmed_after_guest_exit,
+ );
+ }
+
+ pub fn wait_for_host_to_descend_into_guest(&self) {
+ self.wait_for_host_to_descend_into_guest_after(0);
+ }
+
+ pub fn wait_for_host_to_descend_into_guest_after(&self, since: usize) {
+ self.host_to_guest.wait_for_after(
+ since,
+ "the outer (host) session to descend into the guest pane and route the client's keys down into it",
+ |message| matches!(message, NestedSessionMessage::FocusGained { .. }),
+ );
+ }
+
+ pub fn descend_into_guest_via_modal(&self) {
+ let descended = self.mark_host_to_guest();
+ self.host.send_stdin(b"2");
+ self.wait_for_host_to_descend_into_guest_after(descended);
+ }
+
+ pub fn zoom_into_guest_via_modal(&self) {
+ let descended = self.mark_host_to_guest();
+ self.host.send_stdin(b"1");
+ self.wait_for_host_to_descend_into_guest_after(descended);
+ }
+
+ pub fn wait_for_host_to_ascend_from_guest(&self) {
+ self.wait_for_host_to_ascend_from_guest_after(0);
+ }
+
+ pub fn wait_for_host_to_ascend_from_guest_after(&self, since: usize) {
+ self.host_to_guest.wait_for_after(
+ since,
+ "the outer (host) session to ascend back out of the guest pane and take its client's keys back",
+ |message| matches!(message, NestedSessionMessage::FocusLost),
+ );
+ }
+
+ pub fn wait_for_guest_to_request_host_focus(&self) {
+ self.wait_for_guest_to_request_host_focus_after(0);
+ }
+
+ pub fn wait_for_guest_to_request_host_focus_after(&self, since: usize) {
+ self.guest_to_host.wait_for_after(
+ since,
+ "the inner (guest) session to ask its host to take focus back (the FocusHostSession binding)",
+ |message| matches!(message, NestedSessionMessage::FocusHost { .. }),
+ );
+ }
+
+ pub fn wait_until_host_composites_settled_guest(
+ &self,
+ what: &str,
+ guest_settled: impl Fn(&GridSnapshot) -> bool,
+ host_settled: impl Fn(&GridSnapshot) -> bool,
+ ) -> GridSnapshot {
+ wait_for_settled_composite(&self.host, &self.guest, what, guest_settled, host_settled)
+ }
+
+ pub fn focus_gained_count(&self) -> usize {
+ self.host_to_guest
+ .count(|message| matches!(message, NestedSessionMessage::FocusGained { .. }))
+ }
+
+ pub fn focus_lost_count(&self) -> usize {
+ self.host_to_guest
+ .count(|message| matches!(message, NestedSessionMessage::FocusLost))
+ }
+
+ pub fn freeze_guest(&self) {
+ self.frozen.store(true, Ordering::SeqCst);
+ }
+
+ fn host_ping_count(&self) -> usize {
+ self.host_to_guest
+ .count(|message| matches!(message, NestedSessionMessage::Ping))
+ }
+
+ pub fn assert_host_stops_pinging_frozen_guest(&self) {
+ let stable_window = Duration::from_millis(6 * GUEST_PING_INTERVAL_MS);
+ let deadline = Instant::now()
+ + Duration::from_millis(GUEST_PONG_TIMEOUT_MS)
+ + crate::default_timeout();
+ loop {
+ let pings_before = self.host_ping_count();
+ thread::sleep(stable_window);
+ let pings_after = self.host_ping_count();
+ if pings_before == pings_after {
+ return;
+ }
+ if Instant::now() >= deadline {
+ panic!(
+ "the outer host kept pinging the guest after it went silent; \
+ the host never noticed the guest was gone and cleared it \
+ (pings before={pings_before}, after={pings_after})",
+ );
+ }
+ }
+ }
+}
+
+pub struct NestedDepthThreeHarness {
+ pub outer: TestSession,
+ pub middle: TestSession,
+ pub inner: TestSession,
+ pub middle_pane: FakePtyHandle,
+ pub inner_pane: FakePtyHandle,
+ outer_to_middle: FrameLog,
+ middle_to_inner: FrameLog,
+ inner_to_middle: FrameLog,
+ _frozen: Arc<AtomicBool>,
+ _forwarders: Vec<JoinHandle<()>>,
+}
+
+impl NestedDepthThreeHarness {
+ pub fn start_depth_three(outer_size: Size) -> Self {
+ set_nested_timing_env();
+
+ let outer = TestRunner::new(outer_size).start();
+ outer.wait_for_app_load();
+ let middle_pane = outer.expect_pty_spawn();
+ let outer_session_name = outer.session_name().to_string();
+
+ let frozen = Arc::new(AtomicBool::new(false));
+ let outer_to_middle_bridge =
+ bridge_guest_into_pane_for_host(&middle_pane, &outer_session_name, frozen.clone());
+ let middle = outer_to_middle_bridge.guest;
+ let outer_to_middle = outer_to_middle_bridge.host_to_guest;
+
+ middle.wait_for_app_load();
+ let inner_pane = middle.expect_pty_spawn();
+ let middle_session_name = middle.session_name().to_string();
+
+ let middle_to_inner_bridge =
+ bridge_guest_into_pane_for_host(&inner_pane, &middle_session_name, frozen.clone());
+ let inner = middle_to_inner_bridge.guest;
+ let middle_to_inner = middle_to_inner_bridge.host_to_guest;
+ let inner_to_middle = middle_to_inner_bridge.guest_to_host;
+
+ let mut forwarders = outer_to_middle_bridge.forwarders;
+ forwarders.extend(middle_to_inner_bridge.forwarders);
+
+ NestedDepthThreeHarness {
+ outer,
+ middle,
+ inner,
+ middle_pane,
+ inner_pane,
+ outer_to_middle,
+ middle_to_inner,
+ inner_to_middle,
+ _frozen: frozen,
+ _forwarders: forwarders,
+ }
+ }
+
+ pub fn outer_to_middle_frames(&self) -> &FrameLog {
+ &self.outer_to_middle
+ }
+
+ pub fn middle_to_inner_frames(&self) -> &FrameLog {
+ &self.middle_to_inner
+ }
+
+ pub fn inner_to_middle_frames(&self) -> &FrameLog {
+ &self.inner_to_middle
+ }
+
+ pub fn wait_for_outer_to_descend_into_middle(&self) {
+ self.outer_to_middle.wait_for(
+ "the outer host to descend into the middle guest pane",
+ |message| matches!(message, NestedSessionMessage::FocusGained { .. }),
+ );
+ }
+
+ pub fn wait_for_middle_to_descend_into_inner(&self) {
+ self.middle_to_inner.wait_for(
+ "the middle guest to descend into the inner guest pane",
+ |message| matches!(message, NestedSessionMessage::FocusGained { .. }),
+ );
+ }
+
+ pub fn descend_outer_into_middle_via_modal(&self) {
+ let descended = self.mark_outer_to_middle();
+ self.outer.send_stdin(b"2");
+ self.wait_for_outer_to_descend_into_middle_after(descended);
+ }
+
+ pub fn descend_middle_into_inner_via_modal(&self) {
+ self.middle_to_inner.wait_for(
+ "the inner guest to announce itself to the middle host before descending",
+ |message| matches!(message, NestedSessionMessage::AnnounceAck { .. }),
+ );
+ let descended = self.mark_middle_to_inner();
+ self.outer.send_stdin(b"2");
+ self.wait_for_middle_to_descend_into_inner_after(descended);
+ }
+
+ pub fn zoom_middle_into_inner_via_modal(&self) {
+ self.middle_to_inner.wait_for(
+ "the inner guest to announce itself to the middle host before zooming",
+ |message| matches!(message, NestedSessionMessage::AnnounceAck { .. }),
+ );
+ let descended = self.mark_middle_to_inner();
+ self.outer.send_stdin(b"1");
+ self.wait_for_middle_to_descend_into_inner_after(descended);
+ }
+
+ pub fn boot_and_descend_depth_three(&self) {
+ self.descend_outer_into_middle_via_modal();
+ self.middle.wait_for_app_load();
+ self.descend_middle_into_inner_via_modal();
+ }
+
+ pub fn wait_for_outer_to_descend_into_middle_after(&self, since: usize) {
+ self.outer_to_middle.wait_for_after(
+ since,
+ "the outer host to descend back into the middle guest pane",
+ |message| matches!(message, NestedSessionMessage::FocusGained { .. }),
+ );
+ }
+
+ pub fn wait_for_middle_to_descend_into_inner_after(&self, since: usize) {
+ self.middle_to_inner.wait_for_after(
+ since,
+ "the middle guest to descend back into the inner guest pane",
+ |message| matches!(message, NestedSessionMessage::FocusGained { .. }),
+ );
+ }
+
+ pub fn mark_outer_to_middle(&self) -> usize {
+ self.outer_to_middle.mark()
+ }
+
+ pub fn mark_middle_to_inner(&self) -> usize {
+ self.middle_to_inner.mark()
+ }
+
+ pub fn mark_inner_to_middle(&self) -> usize {
+ self.inner_to_middle.mark()
+ }
+
+ pub fn wait_for_outer_to_ascend_from_middle_after(&self, since: usize) {
+ self.outer_to_middle.wait_for_after(
+ since,
+ "the outer host to ascend back out of the middle guest pane",
+ |message| matches!(message, NestedSessionMessage::FocusLost),
+ );
+ }
+
+ pub fn wait_for_outer_to_reclaim_focus_after_middle_exit(&self) {
+ self.outer.wait_until(
+ "the outer host to notice the middle guest is gone and reclaim focus (undim its chrome)",
+ host_chrome_undimmed_after_guest_exit,
+ );
+ }
+
+ pub fn wait_for_middle_to_ascend_from_inner_after(&self, since: usize) {
+ self.middle_to_inner.wait_for_after(
+ since,
+ "the middle guest to ascend back out of the inner guest pane",
+ |message| matches!(message, NestedSessionMessage::FocusLost),
+ );
+ }
+
+ pub fn wait_for_inner_to_request_host_focus_after(&self, since: usize) {
+ self.inner_to_middle.wait_for_after(
+ since,
+ "the inner guest to ask its middle host to take focus back (the FocusHostSession binding)",
+ |message| matches!(message, NestedSessionMessage::FocusHost { .. }),
+ );
+ }
+}
+
+fn spawn_guest_to_host(
+ guest_stdout_rx: Receiver<Vec<u8>>,
+ host_pane: FakePtyHandle,
+ frozen: Arc<AtomicBool>,
+ log: FrameLog,
+) -> JoinHandle<()> {
+ thread::Builder::new()
+ .name("nested_guest_to_host".to_string())
+ .spawn(move || {
+ let mut extractor = NestedFrameExtractor::new();
+ while let Ok(bytes) = guest_stdout_rx.recv() {
+ if frozen.load(Ordering::SeqCst) {
+ continue;
+ }
+ let (_residue, payloads) = extractor.extract(&bytes);
+ for payload in payloads {
+ if let Some(message) = decode_payload(&payload) {
+ log.push(message);
+ }
+ }
+ host_pane.try_output(&bytes);
+ }
+ })
+ .unwrap()
+}
+
+fn spawn_host_to_guest(
+ host_stdin_rx: Receiver<Vec<u8>>,
+ guest_stdin_tx: Sender<Vec<u8>>,
+ log: FrameLog,
+) -> JoinHandle<()> {
+ thread::Builder::new()
+ .name("nested_host_to_guest".to_string())
+ .spawn(move || {
+ let mut extractor = NestedFrameExtractor::new();
+ while let Ok(bytes) = host_stdin_rx.recv() {
+ let (_residue, payloads) = extractor.extract(&bytes);
+ for payload in payloads {
+ if let Some(message) = decode_payload(&payload) {
+ log.push(message);
+ }
+ }
+ if guest_stdin_tx.send(bytes).is_err() {
+ break;
+ }
+ }
+ })
+ .unwrap()
+}
+
+fn spawn_pane_resize_to_guest(
+ host_pane: FakePtyHandle,
+ guest_resizer: GuestResizer,
+ initial_cols: u16,
+ initial_rows: u16,
+) -> JoinHandle<()> {
+ thread::Builder::new()
+ .name("nested_pane_resize_to_guest".to_string())
+ .spawn(move || {
+ let mut last_seen = (initial_cols, initial_rows);
+ while let Some((cols, rows)) = host_pane.wait_for_size_change(last_seen) {
+ last_seen = (cols, rows);
+ let resized = guest_resizer.resize(Size {
+ cols: cols as usize,
+ rows: rows as usize,
+ });
+ if !resized {
+ break;
+ }
+ }
+ })
+ .unwrap()
+}
diff --git a/zellij-integration-tests/src/runner.rs b/zellij-integration-tests/src/runner.rs
index cee765a9..57c994b8 100644
--- a/zellij-integration-tests/src/runner.rs
+++ b/zellij-integration-tests/src/runner.rs
@@ -1,6 +1,8 @@
use std::path::PathBuf;
+use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
+use std::time::Duration;
use zellij_client::os_input_output::SignalEvent;
use zellij_client::ClientInfo;
@@ -21,6 +23,9 @@ pub struct TestRunner {
size: Size,
extra_config_kdl: String,
layout: Option<LayoutInfo>,
+ env: std::collections::HashMap<String, String>,
+ stdout_tap: Option<crossbeam::channel::Sender<Vec<u8>>>,
+ skip_concurrency_slot: bool,
}
impl TestRunner {
@@ -29,6 +34,9 @@ impl TestRunner {
size,
extra_config_kdl: String::new(),
layout: None,
+ env: std::collections::HashMap::new(),
+ stdout_tap: None,
+ skip_concurrency_slot: false,
}
}
@@ -43,6 +51,22 @@ impl TestRunner {
self
}
+ pub fn as_nested_guest(mut self, host_session_name: &str) -> Self {
+ self.env
+ .insert("ZELLIJ".to_string(), host_session_name.to_string());
+ self
+ }
+
+ pub fn with_stdout_tap(mut self, sender: crossbeam::channel::Sender<Vec<u8>>) -> Self {
+ self.stdout_tap = Some(sender);
+ self
+ }
+
+ pub fn skip_concurrency_slot(mut self) -> Self {
+ self.skip_concurrency_slot = true;
+ self
+ }
+
pub fn start(self) -> TestSession {
test_env::init();
let session_name = test_env::unique_session_name();
@@ -57,14 +81,21 @@ impl TestRunner {
let (config, default_layout_info, config_options, _, _) =
Setup::from_cli_args(&cli_args).expect("failed to load harness config");
- let concurrency_slot = test_env::acquire_concurrency_slot();
+ let concurrency_slot = if self.skip_concurrency_slot {
+ None
+ } else {
+ Some(test_env::acquire_concurrency_slot())
+ };
let fake_server_os_api = FakeServerOsApi::default();
let server_thread: Arc<Mutex<Option<JoinHandle<()>>>> = Arc::new(Mutex::new(None));
let server_spawner = in_process_server_spawner(fake_server_os_api.clone(), &server_thread);
let (fake_client_os_api, fake_client_handle) =
- FakeClientOsApi::new(self.size, Some(server_spawner));
+ FakeClientOsApi::new_with_env(self.size, Some(server_spawner), self.env);
+ if let Some(stdout_tap) = self.stdout_tap {
+ fake_client_handle.client_screen.set_stdout_tap(stdout_tap);
+ }
let layout_info = self.layout.or(default_layout_info);
let client_thread = spawn_client_thread(
fake_client_os_api,
@@ -194,6 +225,19 @@ pub struct TestClient {
thread: Option<JoinHandle<Option<ConnectToSession>>>,
}
+#[derive(Clone)]
+pub struct GuestResizer {
+ size: Arc<Mutex<Size>>,
+ signal_tx: crossbeam::channel::Sender<SignalEvent>,
+}
+
+impl GuestResizer {
+ pub fn resize(&self, new_size: Size) -> bool {
+ *self.size.lock().unwrap() = new_size;
+ self.signal_tx.send(SignalEvent::Resize).is_ok()
+ }
+}
+
impl TestClient {
pub fn send_stdin(&self, bytes: &[u8]) {
self.fake_client_handle
@@ -217,6 +261,12 @@ impl TestClient {
.wait_until(what, predicate)
}
+ pub fn wait_until_raw_output(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> {
+ self.fake_client_handle
+ .client_screen
+ .wait_until_raw_output(what, predicate)
+ }
+
pub fn snapshot(&self) -> GridSnapshot {
self.fake_client_handle.client_screen.snapshot()
}
@@ -227,14 +277,47 @@ impl TestClient {
}
fn join(&mut self) -> Option<ConnectToSession> {
- self.thread
- .take()
- .and_then(|join_handle: JoinHandle<Option<ConnectToSession>>| {
- join_handle.join().expect("client thread panicked")
- })
+ self.thread.take().and_then(join_client_thread_with_timeout)
}
}
+fn join_client_thread_with_timeout(
+ join_handle: JoinHandle<Option<ConnectToSession>>,
+) -> Option<ConnectToSession> {
+ let (done_tx, done_rx) = mpsc::channel();
+ std::thread::Builder::new()
+ .name("nested_client_join_watchdog".to_string())
+ .spawn(move || {
+ let result = join_handle.join();
+ let _ = done_tx.send(result);
+ })
+ .unwrap();
+ match done_rx.recv_timeout(CLIENT_JOIN_TIMEOUT) {
+ Ok(Ok(reconnect)) => reconnect,
+ Ok(Err(_)) => panic!("client thread panicked"),
+ Err(mpsc::RecvTimeoutError::Timeout) => None,
+ Err(mpsc::RecvTimeoutError::Disconnected) => None,
+ }
+}
+
+fn join_server_thread_with_timeout(join_handle: JoinHandle<()>) {
+ let (done_tx, done_rx) = mpsc::channel();
+ std::thread::Builder::new()
+ .name("nested_server_join_watchdog".to_string())
+ .spawn(move || {
+ let result = join_handle.join();
+ let _ = done_tx.send(result);
+ })
+ .unwrap();
+ match done_rx.recv_timeout(CLIENT_JOIN_TIMEOUT) {
+ Ok(Ok(())) => {},
+ Ok(Err(_)) => panic!("server thread panicked"),
+ Err(_) => {},
+ }
+}
+
+const CLIENT_JOIN_TIMEOUT: Duration = Duration::from_secs(10);
+
pub struct TestSession {
session_name: String,
size: Size,
@@ -244,7 +327,7 @@ pub struct TestSession {
fake_server_os_api: FakeServerOsApi,
server_thread: Arc<Mutex<Option<JoinHandle<()>>>>,
main_client: TestClient,
- _concurrency_slot: test_env::ConcurrencySlot,
+ _concurrency_slot: Option<test_env::ConcurrencySlot>,
}
pub struct CliClientHandle {
@@ -279,10 +362,21 @@ impl TestSession {
self.main_client.send_stdin(bytes);
}
+ pub fn stdin_sender(&self) -> crossbeam::channel::Sender<Vec<u8>> {
+ self.main_client.fake_client_handle.stdin_tx.clone()
+ }
+
pub fn resize(&self, new_size: Size) {
self.main_client.resize(new_size);
}
+ pub fn resize_sender(&self) -> GuestResizer {
+ GuestResizer {
+ size: self.main_client.fake_client_handle.size.clone(),
+ signal_tx: self.main_client.fake_client_handle.signal_tx.clone(),
+ }
+ }
+
pub fn wait_until(
&self,
what: &str,
@@ -291,13 +385,17 @@ impl TestSession {
self.main_client.wait_until(what, predicate)
}
+ pub fn wait_until_raw_output(&self, what: &str, predicate: impl Fn(&[u8]) -> bool) -> Vec<u8> {
+ self.main_client.wait_until_raw_output(what, predicate)
+ }
+
pub fn snapshot(&self) -> GridSnapshot {
self.main_client.snapshot()
}
pub fn wait_for_app_load(&self) -> GridSnapshot {
self.main_client.wait_until("app to load", |grid_snapshot| {
- grid_snapshot.status_bar_appears()
+ (grid_snapshot.status_bar_appears() || grid_snapshot.contains("Descend:"))
&& grid_snapshot.tab_bar_appears()
&& grid_snapshot.cursor.is_some()
})
@@ -504,7 +602,7 @@ impl TestSession {
self.main_client.join();
}
if let Some(server_thread) = self.server_thread.lock().unwrap().take() {
- server_thread.join().expect("server thread panicked");
+ join_server_thread_with_timeout(server_thread);
}
}
@@ -532,6 +630,15 @@ pub fn normalized(grid_snapshot: &GridSnapshot) -> String {
strip_trailing_whitespace(&text)
}
+pub fn assert_same_rendered_grid(actual: &GridSnapshot, expected: &GridSnapshot, what: &str) {
+ let actual = normalized(actual);
+ let expected = normalized(expected);
+ assert!(
+ actual == expected,
+ "{what}\n--- expected grid ---\n{expected}\n--- actual grid ---\n{actual}"
+ );
+}
+
fn strip_tip_indication(text: &str) -> String {
regex::Regex::new(r" Tip: [^\n]*")
.unwrap()
diff --git a/zellij-integration-tests/tests/about_pane.rs b/zellij-integration-tests/tests/about_pane.rs
new file mode 100644
index 00000000..9c203563
--- /dev/null
+++ b/zellij-integration-tests/tests/about_pane.rs
@@ -0,0 +1,59 @@
+#![cfg(unix)]
+
+use zellij_integration_tests::{
+ keys, FakePtyHandle, Size, TestRunner, TestSession, PROMPT, TERMINAL_SIZE,
+};
+
+const PIN_MARKER: &str = "PIN [ ]";
+
+fn start_zellij_with_startup_tip() -> (TestSession, FakePtyHandle) {
+ let zellij = TestRunner::new(TERMINAL_SIZE)
+ .with_config("show_startup_tips true")
+ .start();
+ let terminal = zellij.expect_pty_spawn();
+ terminal.output(PROMPT);
+ (zellij, terminal)
+}
+
+#[test]
+fn hidden_startup_tip_stays_hidden_after_terminal_resize() {
+ let (mut zellij, terminal) = start_zellij_with_startup_tip();
+ zellij.wait_until("startup tip floating pane shown", |grid_snapshot| {
+ grid_snapshot.contains(PIN_MARKER)
+ });
+ zellij.send_stdin(&keys::alt('f'));
+ zellij.wait_until("floating panes hidden", |grid_snapshot| {
+ !grid_snapshot.contains(PIN_MARKER) && grid_snapshot.status_bar_appears()
+ });
+
+ zellij.resize(Size {
+ cols: 140,
+ rows: 30,
+ });
+ terminal.wait_for_size(
+ "terminal pane resized to the larger display",
+ |cols, _rows| cols > 120,
+ );
+ zellij.wait_until("display settled at the new size", |grid_snapshot| {
+ grid_snapshot.status_bar_appears()
+ });
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.wait_until("pane mode entered after the resize", |grid_snapshot| {
+ grid_snapshot
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains("Fullscreen"))
+ });
+ zellij.send_stdin(&keys::ESC);
+ let settled = zellij.wait_until("normal mode restored after the resize", |grid_snapshot| {
+ grid_snapshot.status_bar_appears()
+ });
+
+ assert!(
+ !settled.contains(PIN_MARKER),
+ "hidden startup tip floating pane must stay hidden after a terminal resize\n{}",
+ settled.text,
+ );
+ zellij.quit();
+}
diff --git a/zellij-integration-tests/tests/app.rs b/zellij-integration-tests/tests/app.rs
index 45e57512..45f50c22 100644
--- a/zellij-integration-tests/tests/app.rs
+++ b/zellij-integration-tests/tests/app.rs
@@ -61,6 +61,34 @@ fn resize_terminal_window() {
}
#[test]
+fn resize_re_queries_pixel_sizes() {
+ let mut zellij = TestRunner::new(TERMINAL_SIZE).start();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+
+ zellij.wait_until_raw_output("startup pixel size query", |bytes| {
+ count_pixel_size_queries(bytes) == 1
+ });
+
+ zellij.resize(Size {
+ cols: 100,
+ rows: 24,
+ });
+
+ zellij.wait_until_raw_output("pixel size re-query after resize", |bytes| {
+ count_pixel_size_queries(bytes) >= 2
+ });
+ zellij.quit();
+}
+
+fn count_pixel_size_queries(client_stdout_bytes: &[u8]) -> usize {
+ let pixel_size_query = b"\x1b[14t\x1b[16t";
+ client_stdout_bytes
+ .windows(pixel_size_query.len())
+ .filter(|window| window == pixel_size_query)
+ .count()
+}
+
+#[test]
fn status_bar_loads_custom_keybindings() {
let zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(CHANGED_KEYS_CONFIG)
diff --git a/zellij-integration-tests/tests/nested_fullscreen.rs b/zellij-integration-tests/tests/nested_fullscreen.rs
new file mode 100644
index 00000000..adb98644
--- /dev/null
+++ b/zellij-integration-tests/tests/nested_fullscreen.rs
@@ -0,0 +1,1147 @@
+#![cfg(unix)]
+
+use insta::assert_snapshot;
+use zellij_integration_tests::{
+ claim_first_terminal_and_wait_for_prompt, col, host_descended_bar_with_ascend_keys_settled,
+ keys, normalized, split_right_and_wait_for_prompt, start_zellij, GridSnapshot,
+ NestedDepthThreeHarness, NestedHarness, TERMINAL_SIZE,
+};
+use zellij_utils::cli::CliAction;
+use zellij_utils::nested_session::NestedSessionMessage;
+
+const ASCEND_KEY: &[u8] = b"]";
+const DESCEND_KEY: &[u8] = b"[";
+const ARROW_LEFT: &[u8] = b"\x1b[D";
+
+fn sgr_mouse_report(column: usize, line: usize, button: u8) -> Vec<u8> {
+ format!("\u{1b}[<{};{};{}M", button, column, line).into_bytes()
+}
+
+fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool {
+ grid_snapshot
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains(needle))
+}
+
+fn session_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "SESSION") && last_line_contains(grid_snapshot, "Detach")
+}
+
+fn pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "Fullscreen")
+}
+
+fn guest_normal_mode_bar_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_grid.lines().last().map_or(false, |last_line| {
+ if last_line.contains("LOCK") {
+ return true;
+ }
+ let tokens: Vec<String> = last_line
+ .replace('\u{e0b0}', " ")
+ .split_whitespace()
+ .map(|token| token.to_owned())
+ .collect();
+ ["g", "p", "t", "n", "h", "s", "o", "q"]
+ .iter()
+ .all(|mode_letter| tokens.iter().any(|token| token == mode_letter))
+ })
+}
+
+fn guest_ui_settled(guest_grid: &GridSnapshot) -> bool {
+ let lines = guest_grid.lines();
+ lines
+ .first()
+ .map_or(false, |first_line| first_line.contains("Tab #1"))
+ && last_line_contains(guest_grid, "Ctrl +")
+ && guest_normal_mode_bar_settled(guest_grid)
+}
+
+fn guest_fullscreen_breadcrumb_line(guest_grid: &GridSnapshot) -> bool {
+ guest_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains('▸') && first_line.contains('(')
+ })
+}
+
+fn breadcrumb_for(ancestry: &[&str], name: &str) -> String {
+ format!("({} ▸ {}) ", ancestry.join(" ▸ "), name)
+}
+
+fn ancestor_segment_range(first_line: &str) -> (usize, usize) {
+ let start = first_line.find('(').expect("breadcrumb open paren present");
+ let after_arrow = first_line[start..]
+ .rfind('▸')
+ .expect("breadcrumb arrow present");
+ let end = start + after_arrow + '▸'.len_utf8();
+ (
+ first_line[..start].chars().count(),
+ first_line[..end].chars().count() + 1,
+ )
+}
+
+fn boot_and_descend_on_first_load(nested: &NestedHarness) {
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.descend_into_guest_via_modal();
+ nested.guest.wait_for_app_load();
+ nested.wait_for_host_to_ping_guest();
+ nested.wait_for_guest_to_reply_to_ping();
+ nested
+ .guest
+ .wait_until("guest settled in normal mode", guest_ui_settled);
+}
+
+fn enter_fullscreen_from_descended_guest(nested: &NestedHarness) {
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the fullscreen bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+}
+
+fn host_own_name_present(host_grid: &GridSnapshot, host_session_name: &str) -> bool {
+ let host_own_slot = format!("({})", host_session_name);
+ host_grid.contains(&host_own_slot)
+}
+
+fn depth_three_descend(nested: &NestedDepthThreeHarness) {
+ nested.boot_and_descend_depth_three();
+ nested.inner.wait_for_app_load();
+ nested
+ .inner
+ .wait_until("inner settled in normal mode", guest_ui_settled);
+}
+
+fn enter_fullscreen_from_descended_inner(nested: &NestedDepthThreeHarness) {
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.inner.wait_until(
+ "inner entered session mode before the fullscreen bind",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(&keys::key('f'));
+}
+
+#[test]
+fn guest_fullscreen_fills_the_host_display_and_hides_chrome() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+
+ let entered = nested.mark_guest_to_host();
+ enter_fullscreen_from_descended_guest(&nested);
+ nested.guest_to_host().wait_for_after(
+ entered,
+ "the descended guest asks its host to fullscreen its pane",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }
+ )
+ },
+ );
+
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ let fullscreen_grid = nested.host.wait_until(
+ "guest content fills the whole host display without host chrome",
+ |host_grid| {
+ host_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(host_grid, &host_session_name)
+ && host_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains(&expected_breadcrumb) && first_line.contains("Tab #1")
+ })
+ && host_grid.cursor_is_at(col(0).row(1))
+ },
+ );
+ assert!(
+ fullscreen_grid.row_of_line("Pane #1").is_none(),
+ "no stray host pane-title line while the guest is fullscreened"
+ );
+ assert_snapshot!(
+ "depth2_guest_fullscreen_fills_display",
+ normalized(&fullscreen_grid)
+ );
+}
+
+#[test]
+fn toggle_off_restores_the_host_layout_exactly() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+
+ let before_fullscreen =
+ nested
+ .host
+ .wait_until("host chrome present before fullscreen", |host_grid| {
+ host_grid.status_bar_appears()
+ && host_grid.tab_bar_appears()
+ && host_descended_bar_with_ascend_keys_settled(host_grid)
+ && host_own_name_present(host_grid, &host_session_name)
+ && !guest_fullscreen_breadcrumb_line(host_grid)
+ });
+
+ let entered = nested.mark_guest_to_host();
+ enter_fullscreen_from_descended_guest(&nested);
+ nested
+ .guest_to_host()
+ .wait_for_after(entered, "guest asks host to fullscreen", |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }
+ )
+ });
+ nested
+ .host
+ .wait_until("host chrome hidden while fullscreen", |host_grid| {
+ host_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(host_grid, &host_session_name)
+ });
+
+ let exited = nested.mark_guest_to_host();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the toggle-off bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+ nested.guest_to_host().wait_for_after(
+ exited,
+ "guest asks host to exit fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: false }
+ )
+ },
+ );
+
+ let after_fullscreen = nested.host.wait_until(
+ "host chrome and layout restored after toggle-off",
+ |host_grid| {
+ host_grid.status_bar_appears()
+ && host_grid.tab_bar_appears()
+ && host_descended_bar_with_ascend_keys_settled(host_grid)
+ && host_own_name_present(host_grid, &host_session_name)
+ && !guest_fullscreen_breadcrumb_line(host_grid)
+ },
+ );
+ assert_eq!(
+ after_fullscreen.lines().last(),
+ before_fullscreen.lines().last(),
+ "host status bar restored exactly after fullscreen exit"
+ );
+ assert!(
+ after_fullscreen
+ .lines()
+ .first()
+ .map_or(false, |first_line| !first_line.contains('▸')),
+ "breadcrumb gone from the host tab-bar after fullscreen exit"
+ );
+}
+
+#[test]
+fn fullscreen_state_reaches_the_guest_on_enter_and_exit() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ let entered = nested.mark_host_to_guest();
+ enter_fullscreen_from_descended_guest(&nested);
+ nested.host_to_guest().wait_for_after(
+ entered,
+ "the guest is told it entered host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+ nested
+ .guest
+ .wait_until("guest breadcrumb shown while fullscreen", |guest_grid| {
+ guest_fullscreen_breadcrumb_line(guest_grid)
+ });
+
+ let exited = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the toggle-off bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+ nested.host_to_guest().wait_for_after(
+ exited,
+ "the guest is told it left host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ },
+ );
+ nested
+ .guest
+ .wait_until("guest breadcrumb cleared after exit", |guest_grid| {
+ !guest_fullscreen_breadcrumb_line(guest_grid)
+ });
+}
+
+fn column_of(first_line: &str, needle: &str) -> usize {
+ let byte_index = first_line.find(needle).expect("needle present on line");
+ first_line[..byte_index].chars().count()
+}
+
+#[test]
+fn breadcrumb_is_present_only_while_fullscreened() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+
+ let before = nested
+ .guest
+ .wait_until("guest tab-bar settled before fullscreen", guest_ui_settled);
+ assert!(
+ !guest_fullscreen_breadcrumb_line(&before)
+ && before
+ .lines()
+ .first()
+ .map_or(true, |first_line| !first_line.contains('▸')),
+ "no breadcrumb on the guest tab-bar before fullscreen"
+ );
+
+ enter_fullscreen_from_descended_guest(&nested);
+
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ let fullscreened = nested.guest.wait_until(
+ "guest tab-bar shows the breadcrumb while fullscreened",
+ |guest_grid| {
+ guest_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains(&expected_breadcrumb)
+ })
+ },
+ );
+ let first_line = fullscreened.lines().into_iter().next().unwrap();
+ assert!(
+ first_line.contains(&format!("{})", guest_session_name)),
+ "the guest's own name is present in the breadcrumb"
+ );
+
+ let ancestor_column = column_of(&first_line, &host_session_name);
+ assert!(
+ fullscreened.char_is_italic(ancestor_column, 0),
+ "the ancestor segment of the breadcrumb is styled italic"
+ );
+ let name_column = column_of(&first_line, &guest_session_name);
+ assert!(
+ fullscreened.char_is_bold(name_column, 0),
+ "the guest's own name in the breadcrumb is styled bold"
+ );
+ assert!(
+ !fullscreened.char_is_dim(ancestor_column, 0),
+ "the ancestor segment is not dimmed"
+ );
+ assert_snapshot!(
+ "depth2_breadcrumb_tab_bar_line",
+ normalized(&fullscreened).lines().next().unwrap()
+ );
+
+ let exited = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the toggle-off bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+ nested.host_to_guest().wait_for_after(
+ exited,
+ "guest told it left host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ },
+ );
+ nested.guest.wait_until(
+ "guest breadcrumb gone after fullscreen exit",
+ |guest_grid| {
+ guest_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| !first_line.contains('▸'))
+ },
+ );
+}
+
+#[test]
+fn nested_but_tiled_guest_shows_no_breadcrumb() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ let tiled = nested
+ .guest
+ .wait_until("nested guest tab-bar settled while tiled", guest_ui_settled);
+ assert!(
+ tiled
+ .lines()
+ .first()
+ .map_or(true, |first_line| { !first_line.contains('▸') }),
+ "a nested but not fullscreened guest shows no breadcrumb"
+ );
+}
+
+#[test]
+fn standalone_no_ui_fullscreen_shows_no_breadcrumb() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('F'));
+
+ let fullscreened = zellij.wait_until(
+ "standalone pane covers the display without chrome",
+ |grid| {
+ !grid.tab_bar_appears()
+ && !grid.status_bar_appears()
+ && grid.cursor_is_at(col(2).row(0))
+ },
+ );
+ assert!(
+ !fullscreened.contains("▸"),
+ "a standalone (non-nested) NoUi fullscreen shows no breadcrumb"
+ );
+ zellij.quit();
+}
+
+#[test]
+fn innermost_fullscreen_fills_the_whole_outer_terminal_and_zooms_the_chain() {
+ let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE);
+ let outer_session_name = nested.outer.session_name().to_string();
+ let middle_session_name = nested.middle.session_name().to_string();
+ let inner_session_name = nested.inner.session_name().to_string();
+
+ depth_three_descend(&nested);
+
+ let bubbled = nested.mark_inner_to_middle();
+ let outer_told = nested.mark_outer_to_middle();
+ let middle_told = nested.mark_middle_to_inner();
+ enter_fullscreen_from_descended_inner(&nested);
+
+ nested.inner_to_middle_frames().wait_for_after(
+ bubbled,
+ "inner asks the middle host to fullscreen it",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }
+ )
+ },
+ );
+ nested.outer_to_middle_frames().wait_for_after(
+ outer_told,
+ "outer tells the middle it is in host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+ nested.middle_to_inner_frames().wait_for_after(
+ middle_told,
+ "middle tells the inner it is in host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+
+ let expected_breadcrumb = breadcrumb_for(
+ &[&outer_session_name, &middle_session_name],
+ &inner_session_name,
+ );
+ let fullscreen_grid = nested.outer.wait_until(
+ "inner content fills the whole outer display with all three ancestry segments",
+ |outer_grid| {
+ outer_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(outer_grid, &outer_session_name)
+ && !host_own_name_present(outer_grid, &middle_session_name)
+ && outer_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains(&expected_breadcrumb) && first_line.contains("Tab #1")
+ })
+ && outer_grid.cursor_is_at(col(0).row(1))
+ },
+ );
+ assert_snapshot!(
+ "depth3_inner_fullscreen_breadcrumb",
+ normalized(&fullscreen_grid)
+ );
+}
+
+#[test]
+fn zoom_modal_while_outer_is_ascended_still_fullscreens_the_outer() {
+ let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE);
+ let outer_session_name = nested.outer.session_name().to_string();
+ let middle_session_name = nested.middle.session_name().to_string();
+ let inner_session_name = nested.inner.session_name().to_string();
+
+ nested.descend_outer_into_middle_via_modal();
+ nested.middle.wait_for_app_load();
+
+ let ascended = nested.mark_outer_to_middle();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.middle.wait_until(
+ "middle entered session mode before the ascend key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ASCEND_KEY);
+ nested.wait_for_outer_to_ascend_from_middle_after(ascended);
+ nested.outer.wait_until(
+ "outer restored its own chrome after ascending out of the middle",
+ |outer_grid| host_own_name_present(outer_grid, &outer_session_name),
+ );
+
+ let outer_told = nested.mark_outer_to_middle();
+ nested.zoom_middle_into_inner_via_modal();
+ nested.outer_to_middle_frames().wait_for_after(
+ outer_told,
+ "outer honors the fullscreen chain even though it is ascended out of the middle",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+
+ let expected_breadcrumb = breadcrumb_for(
+ &[&outer_session_name, &middle_session_name],
+ &inner_session_name,
+ );
+ nested.outer.wait_until(
+ "the ascended outer drops its own chrome and shows the full nested fullscreen breadcrumb",
+ |outer_grid| {
+ outer_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(outer_grid, &outer_session_name)
+ && !host_own_name_present(outer_grid, &middle_session_name)
+ },
+ );
+}
+
+fn ascend_inner_to_middle(nested: &NestedDepthThreeHarness) {
+ let requested = nested.mark_inner_to_middle();
+ let ascended = nested.mark_middle_to_inner();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.inner.wait_until(
+ "inner entered session mode before ascending to the middle",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ASCEND_KEY);
+ nested.wait_for_inner_to_request_host_focus_after(requested);
+ nested.wait_for_middle_to_ascend_from_inner_after(ascended);
+}
+
+fn redescend_middle_into_inner(nested: &NestedDepthThreeHarness) {
+ let descended = nested.mark_middle_to_inner();
+ nested.outer.send_stdin(&keys::ctrl('p'));
+ nested.middle.wait_until(
+ "middle entered pane mode before re-descending into the inner",
+ |middle_grid| {
+ last_line_contains(middle_grid, "PANE") && last_line_contains(middle_grid, "Move")
+ },
+ );
+ nested.outer.send_stdin(ARROW_LEFT);
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.middle.wait_until(
+ "middle entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(DESCEND_KEY);
+ nested.wait_for_middle_to_descend_into_inner_after(descended);
+}
+
+#[test]
+fn fullscreen_from_inner_while_middle_already_fullscreen_does_not_cancel() {
+ let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE);
+ let outer_session_name = nested.outer.session_name().to_string();
+ let middle_session_name = nested.middle.session_name().to_string();
+ let inner_session_name = nested.inner.session_name().to_string();
+
+ depth_three_descend(&nested);
+
+ ascend_inner_to_middle(&nested);
+
+ nested.outer.send_stdin(&keys::ctrl('p'));
+ nested.middle.wait_until(
+ "middle entered pane mode before splitting a sibling",
+ |middle_grid| {
+ last_line_contains(middle_grid, "PANE") && last_line_contains(middle_grid, "Move")
+ },
+ );
+ nested.outer.send_stdin(&keys::key('r'));
+ let middle_sibling = nested.middle.expect_pty_spawn();
+ middle_sibling.output(b"MIDDLE-SIBLING ");
+ nested.middle.wait_until(
+ "middle spawned a sibling pane next to the inner guest",
+ |middle_grid| middle_grid.contains("MIDDLE-SIBLING"),
+ );
+
+ let outer_told_true = nested.mark_outer_to_middle();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.middle.wait_until(
+ "middle entered session mode before fullscreening itself",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(&keys::key('f'));
+ nested.outer_to_middle_frames().wait_for_after(
+ outer_told_true,
+ "outer fullscreens the middle pane and tells the middle it is fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+
+ let middle_breadcrumb = breadcrumb_for(&[&outer_session_name], &middle_session_name);
+ nested.outer.wait_until(
+ "outer display fullscreened by the middle before the inner enters fullscreen",
+ |outer_grid| {
+ outer_grid.contains(&middle_breadcrumb)
+ && !host_own_name_present(outer_grid, &outer_session_name)
+ },
+ );
+
+ redescend_middle_into_inner(&nested);
+
+ let cancels_before = nested.outer_to_middle_frames().count(|message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ });
+ let bubbled = nested.mark_inner_to_middle();
+ enter_fullscreen_from_descended_inner(&nested);
+ nested.inner_to_middle_frames().wait_for_after(
+ bubbled,
+ "inner asks the middle to fullscreen it",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }
+ )
+ },
+ );
+
+ let inner_breadcrumb = breadcrumb_for(
+ &[&outer_session_name, &middle_session_name],
+ &inner_session_name,
+ );
+ let final_grid = nested.outer.wait_until(
+ "the whole chain ends fullscreen with the inner content covering the outer display",
+ |outer_grid| {
+ outer_grid.contains(&inner_breadcrumb)
+ && !host_own_name_present(outer_grid, &outer_session_name)
+ && !host_own_name_present(outer_grid, &middle_session_name)
+ && outer_grid.cursor_is_at(col(0).row(1))
+ },
+ );
+
+ let cancels_after = nested.outer_to_middle_frames().count(|message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ });
+ assert_eq!(
+ cancels_before, cancels_after,
+ "entering fullscreen at the inner while the middle is already fullscreen must not emit a spurious exit to the outer"
+ );
+ assert_eq!(
+ nested.inner_to_middle_frames().count(|message| matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: false }
+ )),
+ 0,
+ "no ToggleHostFullscreen{{false}} is emitted while entering fullscreen"
+ );
+ assert!(
+ final_grid.contains(&inner_breadcrumb),
+ "the outer display is fullscreen by the inner content at the end"
+ );
+}
+
+const FLOATING_MARKER: &str = "HOST-FLOAT";
+
+fn ascend_via_focus_host_binding(nested: &NestedHarness) {
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ nested.guest.wait_until(
+ "guest settled in normal mode before the mode key is passed through",
+ guest_ui_settled,
+ );
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode after the passed-through mode key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(ASCEND_KEY);
+ nested.wait_for_guest_to_request_host_focus_after(requested);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+}
+
+fn split_host_sibling(nested: &NestedHarness) {
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the split key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('r'));
+ let sibling = nested.host.expect_pty_spawn();
+ sibling.output(b"HOST-SIBLING ");
+ nested.host.wait_until(
+ "host returned to normal mode after the split key",
+ |host_grid| host_grid.contains("HOST-SIBLING"),
+ );
+}
+
+fn descend_into_guest_on_the_left(nested: &NestedHarness) {
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the focus-left key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(ARROW_LEFT);
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.wait_until(
+ "host entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(DESCEND_KEY);
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+}
+
+#[test]
+fn host_floating_layer_is_hidden_during_nested_fullscreen_and_reappears_on_exit() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+ split_host_sibling(&nested);
+
+ let observer = nested.host.attach_client(TERMINAL_SIZE);
+ observer.wait_until(
+ "observer host client attached focused on the sibling host pane",
+ |host_grid| host_grid.contains("HOST-SIBLING") && last_line_contains(host_grid, "LOCK"),
+ );
+
+ observer.send_stdin(&keys::ctrl('p'));
+ observer.wait_until(
+ "observer entered pane mode before the floating pane",
+ |host_grid| host_grid.contains("PANE") && host_grid.contains("Move"),
+ );
+ observer.send_stdin(&keys::key('w'));
+ let floating = nested.host.expect_pty_spawn();
+ floating.output(FLOATING_MARKER.as_bytes());
+ observer.wait_until(
+ "the host floating pane spawned and is visible on the observer",
+ |host_grid| host_grid.contains(FLOATING_MARKER),
+ );
+
+ observer.send_stdin(&keys::alt('f'));
+ observer.wait_until("observer hid the host floating pane", |host_grid| {
+ !host_grid.contains(FLOATING_MARKER)
+ });
+
+ descend_into_guest_on_the_left(&nested);
+ nested
+ .guest
+ .wait_until("guest settled after descend", guest_ui_settled);
+
+ let entered = nested.mark_guest_to_host();
+ enter_fullscreen_from_descended_guest(&nested);
+ nested
+ .guest_to_host()
+ .wait_for_after(entered, "guest asks host to fullscreen", |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }
+ )
+ });
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ nested
+ .host
+ .wait_until("guest fullscreened over the host display", |host_grid| {
+ host_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(host_grid, &host_session_name)
+ });
+
+ observer.send_stdin(&keys::alt('f'));
+ observer.wait_until(
+ "the host floating layer stays hidden while the guest covers the display, even though show_panes was set",
+ |host_grid| !host_grid.contains(FLOATING_MARKER) && !host_grid.contains("HOST-SIBLING"),
+ );
+
+ let exited = nested.mark_guest_to_host();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the toggle-off bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+ nested.guest_to_host().wait_for_after(
+ exited,
+ "guest asks host to exit fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: false }
+ )
+ },
+ );
+
+ observer.wait_until(
+ "the host floating pane renders again on the observer after fullscreen exit",
+ |host_grid| host_grid.contains(FLOATING_MARKER) && host_grid.status_bar_appears(),
+ );
+}
+
+#[test]
+fn restoring_the_innermost_fullscreen_unzooms_the_whole_chain() {
+ let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE);
+ let outer_session_name = nested.outer.session_name().to_string();
+ let middle_session_name = nested.middle.session_name().to_string();
+ let inner_session_name = nested.inner.session_name().to_string();
+
+ depth_three_descend(&nested);
+
+ enter_fullscreen_from_descended_inner(&nested);
+ let expected_breadcrumb = breadcrumb_for(
+ &[&outer_session_name, &middle_session_name],
+ &inner_session_name,
+ );
+ nested.outer.wait_until(
+ "outer display fullscreened by the inner before restore",
+ |outer_grid| {
+ outer_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(outer_grid, &outer_session_name)
+ },
+ );
+
+ let bubbled = nested.mark_inner_to_middle();
+ let outer_told = nested.mark_outer_to_middle();
+ let middle_told = nested.mark_middle_to_inner();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.inner.wait_until(
+ "inner entered session mode before the toggle-off bind",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(&keys::key('f'));
+
+ nested.inner_to_middle_frames().wait_for_after(
+ bubbled,
+ "inner asks the middle host to exit fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: false }
+ )
+ },
+ );
+ nested.outer_to_middle_frames().wait_for_after(
+ outer_told,
+ "outer tells the middle it left host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ },
+ );
+ nested.middle_to_inner_frames().wait_for_after(
+ middle_told,
+ "middle tells the inner it left host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ },
+ );
+
+ nested.outer.wait_until(
+ "outer chrome restored and breadcrumb gone across the whole chain",
+ |outer_grid| {
+ outer_grid.status_bar_appears()
+ && host_own_name_present(outer_grid, &outer_session_name)
+ && outer_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| !first_line.contains('▸'))
+ },
+ );
+ nested.middle.wait_until(
+ "middle chrome restored after chain un-zoom",
+ |middle_grid| {
+ middle_grid.status_bar_appears()
+ && middle_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| !first_line.contains('▸'))
+ },
+ );
+}
+
+#[test]
+fn clicking_the_ancestor_breadcrumb_segment_ascends_out_of_the_guest() {
+ let nested = NestedHarness::start_with_host_and_guest_config(
+ TERMINAL_SIZE,
+ "mouse_mode true",
+ "mouse_mode true",
+ );
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+
+ enter_fullscreen_from_descended_guest(&nested);
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ let fullscreen_grid = nested.host.wait_until(
+ "guest fullscreened with the breadcrumb visible before clicking",
+ |host_grid| {
+ host_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains(&expected_breadcrumb)
+ })
+ },
+ );
+ let first_line = fullscreen_grid.lines().into_iter().next().unwrap();
+ let (ancestor_start, ancestor_end) = ancestor_segment_range(&first_line);
+
+ let non_ascend_before = nested
+ .guest_to_host()
+ .count(|message| matches!(message, NestedSessionMessage::FocusHost { direction: None }));
+ let name_column = column_of(&first_line, &guest_session_name) + 1;
+ nested.host.send_stdin(&sgr_mouse_report(name_column, 1, 0));
+ nested
+ .host
+ .send_stdin(&format!("\u{1b}[<0;{};1m", name_column).into_bytes());
+ nested.guest.wait_until(
+ "guest still fullscreened after a non-ancestor click",
+ |guest_grid| guest_ui_settled(guest_grid),
+ );
+ assert_eq!(
+ nested.guest_to_host().count(|message| matches!(
+ message,
+ NestedSessionMessage::FocusHost { direction: None }
+ )),
+ non_ascend_before,
+ "clicking the own-name part of the breadcrumb must not ascend"
+ );
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ let ancestor_click_column = (ancestor_start + ancestor_end) / 2 + 1;
+ nested
+ .host
+ .send_stdin(&sgr_mouse_report(ancestor_click_column, 1, 0));
+ nested
+ .host
+ .send_stdin(&format!("\u{1b}[<0;{};1m", ancestor_click_column).into_bytes());
+ nested.guest_to_host().wait_for_after(
+ requested,
+ "clicking the ancestor breadcrumb segment asks the host to take focus back",
+ |message| matches!(message, NestedSessionMessage::FocusHost { direction: None }),
+ );
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ nested.host.wait_until(
+ "host regained control and restored its chrome after the breadcrumb click",
+ |host_grid| {
+ host_grid.status_bar_appears() && host_own_name_present(host_grid, &host_session_name)
+ },
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(b"$ ");
+ nested.host.wait_until(
+ "host acts on its own key after the breadcrumb click ascended it",
+ |host_grid| host_grid.contains("Pane #2"),
+ );
+}
+
+#[test]
+fn host_manually_breaking_fullscreen_drifts_the_guest_to_not_fullscreen() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+
+ let entered = nested.mark_host_to_guest();
+ enter_fullscreen_from_descended_guest(&nested);
+ nested.host_to_guest().wait_for_after(
+ entered,
+ "the guest is told it entered host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ nested
+ .host
+ .wait_until("guest fullscreened over the host display", |host_grid| {
+ host_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(host_grid, &host_session_name)
+ });
+
+ let drifted = nested.mark_host_to_guest();
+ let exit_code = nested
+ .host
+ .run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None });
+ assert_eq!(
+ exit_code, 0,
+ "host toggled its own no-ui fullscreen off cleanly"
+ );
+
+ nested.host_to_guest().wait_for_after(
+ drifted,
+ "the host's drift detection tells the guest it is no longer fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: false }
+ )
+ },
+ );
+ nested.guest.wait_until(
+ "guest breadcrumb clears after the host manually broke the fullscreen",
+ |guest_grid| {
+ guest_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| !first_line.contains('▸'))
+ },
+ );
+ nested.host.wait_until(
+ "host chrome restored after manually breaking the fullscreen",
+ |host_grid| {
+ host_grid.status_bar_appears() && host_own_name_present(host_grid, &host_session_name)
+ },
+ );
+}
+
+#[test]
+fn guest_killed_while_fullscreened_unzooms_the_host_after_liveness_timeout() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+
+ let entered = nested.mark_host_to_guest();
+ enter_fullscreen_from_descended_guest(&nested);
+ nested.host_to_guest().wait_for_after(
+ entered,
+ "the guest is told it entered host fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FullscreenState { fullscreen: true }
+ )
+ },
+ );
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ nested.host.wait_until(
+ "guest fullscreen composite fully settled before freezing the guest",
+ |host_grid| {
+ host_grid.contains(&expected_breadcrumb)
+ && !host_own_name_present(host_grid, &host_session_name)
+ && host_grid.cursor_is_at(col(0).row(1))
+ },
+ );
+
+ nested.freeze_guest();
+
+ nested.host.wait_until(
+ "host un-zooms and restores its chrome after the frozen guest times out",
+ |host_grid| {
+ host_grid.status_bar_appears()
+ && host_own_name_present(host_grid, &host_session_name)
+ && host_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| !first_line.contains('▸'))
+ },
+ );
+}
+
+#[test]
+fn fullscreen_breadcrumb_has_no_nested_marker_and_an_emphasized_own_name() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ let host_session_name = nested.host.session_name().to_string();
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ enter_fullscreen_from_descended_guest(&nested);
+
+ let expected_breadcrumb = breadcrumb_for(&[&host_session_name], &guest_session_name);
+ let fullscreened = nested.guest.wait_until(
+ "guest tab-bar shows the breadcrumb while fullscreened",
+ |guest_grid| {
+ guest_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains(&expected_breadcrumb)
+ })
+ },
+ );
+
+ let first_line = fullscreened.lines().into_iter().next().unwrap();
+ assert!(
+ !first_line.contains("[NESTED]"),
+ "the breadcrumb no longer contains the [NESTED] marker"
+ );
+ assert!(
+ first_line.contains(&format!("{})", guest_session_name)) && first_line.contains('▸'),
+ "the breadcrumb shows the guest's own name closed by a paren after the arrow"
+ );
+
+ let ancestor_column = column_of(&first_line, &host_session_name);
+ assert!(
+ fullscreened.char_is_italic(ancestor_column, 0),
+ "the ancestor breadcrumb segment is italic while descended"
+ );
+ assert!(
+ !fullscreened.char_is_dim(ancestor_column, 0),
+ "the descended guest breadcrumb ancestor segment is not dimmed"
+ );
+
+ let name_column = column_of(&first_line, &guest_session_name);
+ assert!(
+ fullscreened.char_is_bold(name_column, 0),
+ "the guest's own name in the breadcrumb is bold while descended"
+ );
+ assert!(
+ !fullscreened.char_is_dim(name_column, 0),
+ "the descended guest breadcrumb own name is not dimmed"
+ );
+}
diff --git a/zellij-integration-tests/tests/nested_guest_modal.rs b/zellij-integration-tests/tests/nested_guest_modal.rs
new file mode 100644
index 00000000..a484256e
--- /dev/null
+++ b/zellij-integration-tests/tests/nested_guest_modal.rs
@@ -0,0 +1,280 @@
+#![cfg(unix)]
+
+use insta::assert_snapshot;
+use zellij_integration_tests::{
+ keys, normalized, GridSnapshot, NestedHarness, PROMPT, TERMINAL_SIZE,
+};
+
+const ARROW_DOWN: &[u8] = b"\x1b[B";
+
+fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool {
+ grid_snapshot
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains(needle))
+}
+
+fn modal_visible(grid_snapshot: &GridSnapshot) -> bool {
+ grid_snapshot.contains("Nested Zellij session detected")
+}
+
+fn grid_contains_line_starting_with(grid_snapshot: &GridSnapshot, prefix: &str) -> bool {
+ grid_snapshot
+ .lines()
+ .iter()
+ .any(|line| line.trim_start().starts_with(prefix))
+}
+
+fn wait_for_modal(nested: &NestedHarness) -> GridSnapshot {
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested
+ .host
+ .wait_until("host shows the guest-detected modal", modal_visible)
+}
+
+fn focus_guest_binding_config() -> &'static str {
+ r#"keybinds {
+ normal {
+ bind "Ctrl y" { FocusGuestSession; }
+ }
+}"#
+}
+
+fn modal_shortcut_binding_config() -> &'static str {
+ r#"keybinds {
+ normal {
+ bind "Ctrl y" { ToggleHostFullscreen; }
+ bind "Ctrl u" { FocusHostSession; }
+ bind "Ctrl i" { FocusGuestSession; }
+ }
+}"#
+}
+
+#[test]
+fn modal_renders_expected_ui() {
+ let mut nested =
+ NestedHarness::start_with_host_config(TERMINAL_SIZE, modal_shortcut_binding_config());
+
+ let host_with_modal = wait_for_modal(&nested);
+ assert!(host_with_modal.contains("What would you like to do?"));
+ assert!(host_with_modal.contains("Zoom in and control this session"));
+ assert!(host_with_modal.contains("Control this session on focus"));
+ assert!(host_with_modal.contains("<Ctrl y>"));
+ assert!(host_with_modal.contains("<Ctrl u>"));
+ assert!(host_with_modal.contains("<↓↑> select"));
+
+ let steady_modal = nested.host.wait_until(
+ "guest-detected modal fully rendered with all options and keybindings",
+ |host_grid| {
+ modal_visible(host_grid)
+ && host_grid.contains("What would you like to do?")
+ && host_grid.contains("Control this session on focus")
+ && host_grid.contains("<Ctrl y>")
+ && host_grid.contains("<Ctrl u>")
+ && host_grid.contains("<↓↑> select")
+ },
+ );
+ assert_snapshot!(normalized(&steady_modal));
+
+ nested.guest.wait_for_app_load();
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn modal_selection_moves_with_arrow_keys() {
+ let mut nested =
+ NestedHarness::start_with_host_config(TERMINAL_SIZE, modal_shortcut_binding_config());
+
+ wait_for_modal(&nested);
+ let first_selection = nested
+ .host
+ .wait_until("first option selected on initial render", |host_grid| {
+ grid_contains_line_starting_with(host_grid, "> 1.")
+ });
+ assert!(grid_contains_line_starting_with(&first_selection, "> 1."));
+ assert!(!grid_contains_line_starting_with(&first_selection, "> 2."));
+
+ nested.host.send_stdin(ARROW_DOWN);
+ let second_selection = nested.host.wait_until(
+ "selection moved to the second option after arrow down",
+ |host_grid| grid_contains_line_starting_with(host_grid, "> 2."),
+ );
+ assert!(grid_contains_line_starting_with(&second_selection, "> 2."));
+ assert!(!grid_contains_line_starting_with(&second_selection, "> 1."));
+
+ nested.guest.wait_for_app_load();
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn modal_appears_on_announce_and_occludes_guest_content() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ let host_with_modal = wait_for_modal(&nested);
+ assert!(modal_visible(&host_with_modal));
+ assert!(host_with_modal.contains("Zoom in and control this session"));
+ assert!(host_with_modal.contains("Control this session on focus"));
+ assert_eq!(
+ nested.focus_gained_count(),
+ 0,
+ "the modal must not enter passthrough by itself"
+ );
+
+ nested.guest.wait_for_app_load();
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn selecting_descend_enters_passthrough() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ wait_for_modal(&nested);
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(ARROW_DOWN);
+ nested.host.send_stdin(b"\r");
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+ nested
+ .host
+ .wait_until("modal dismissed after descend", |host_grid| {
+ !modal_visible(host_grid)
+ });
+
+ nested.guest.wait_for_app_load();
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a pane from the passed-through key after descend",
+ |guest_grid| guest_grid.contains("Pane #2"),
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn digit_one_zooms_and_enters_passthrough() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ wait_for_modal(&nested);
+ let zoomed = nested.mark_host_to_guest();
+ nested.host.send_stdin(b"1");
+ nested.wait_for_host_to_descend_into_guest_after(zoomed);
+ nested
+ .host
+ .wait_until("modal dismissed after zoom", |host_grid| {
+ !modal_visible(host_grid)
+ });
+
+ nested.guest.wait_for_app_load();
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a pane from the passed-through key after zoom",
+ |guest_grid| guest_grid.contains("Pane #2"),
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn focus_guest_session_redescends_after_ascending() {
+ let mut nested =
+ NestedHarness::start_with_host_config(TERMINAL_SIZE, focus_guest_binding_config());
+
+ wait_for_modal(&nested);
+
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(b"2");
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+ nested
+ .host
+ .wait_until("modal answered", |host_grid| !modal_visible(host_grid));
+ nested.guest.wait_for_app_load();
+
+ let ascended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the ascend key",
+ |guest_grid| last_line_contains(guest_grid, "SESSION"),
+ );
+ nested.host.send_stdin(b"]");
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ let redescended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('y'));
+ nested.wait_for_host_to_descend_into_guest_after(redescended);
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a pane from the passed-through key after FocusGuestSession re-descended",
+ |guest_grid| guest_grid.contains("Pane #2"),
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn two_clients_answer_their_own_modals_independently() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ let first_with_modal = wait_for_modal(&nested);
+ assert!(modal_visible(&first_with_modal));
+
+ let second = nested.host.attach_client(TERMINAL_SIZE);
+ let second_with_modal =
+ second.wait_until("the second client also sees the guest modal", modal_visible);
+ assert!(modal_visible(&second_with_modal));
+
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(b"2");
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+ nested.host.wait_until(
+ "the first client's modal is dismissed after it descends",
+ |host_grid| !modal_visible(host_grid),
+ );
+
+ second.wait_until(
+ "the second client's modal stays up while the first client descended",
+ modal_visible,
+ );
+
+ nested.guest.wait_for_app_load();
+ second.quit();
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn host_stops_pinging_a_frozen_guest_after_answering_the_modal() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ wait_for_modal(&nested);
+ nested.guest.wait_for_app_load();
+
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(b"2");
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+ nested
+ .host
+ .wait_until("modal answered", |host_grid| !modal_visible(host_grid));
+
+ nested.freeze_guest();
+ nested.assert_host_stops_pinging_frozen_guest();
+
+ nested.guest.quit();
+ nested.host.quit();
+}
diff --git a/zellij-integration-tests/tests/nested_hardening.rs b/zellij-integration-tests/tests/nested_hardening.rs
new file mode 100644
index 00000000..f0c3bc0b
--- /dev/null
+++ b/zellij-integration-tests/tests/nested_hardening.rs
@@ -0,0 +1,523 @@
+#![cfg(unix)]
+
+use zellij_integration_tests::{
+ guest_ascended_bar_settled, keys, FakePtyHandle, GridSnapshot, NestedHarness, Size, PROMPT,
+ TERMINAL_SIZE,
+};
+use zellij_utils::nested_session::NestedSessionMessage;
+
+const ASCEND_KEY: &[u8] = b"]";
+const DESCEND_KEY: &[u8] = b"[";
+const ARROW_LEFT: &[u8] = b"\x1b[D";
+
+fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool {
+ grid_snapshot
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains(needle))
+}
+
+fn has_breadcrumb(grid: &GridSnapshot) -> bool {
+ grid.lines().first().map_or(false, |l| l.contains('▸'))
+}
+
+fn normal_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "LOCK")
+}
+
+fn pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "Fullscreen")
+}
+
+fn session_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "SESSION") && last_line_contains(grid_snapshot, "Detach")
+}
+
+fn scroll_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "Scroll")
+ && last_line_contains(grid_snapshot, "Edit")
+ && last_line_contains(grid_snapshot, "Select")
+}
+
+fn entering_search_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "ENTERING SEARCH TERM")
+}
+
+fn guest_normal_mode_bar_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_grid.lines().last().map_or(false, |last_line| {
+ if last_line.contains("LOCK") {
+ return true;
+ }
+ let tokens: Vec<String> = last_line
+ .replace('\u{e0b0}', " ")
+ .split_whitespace()
+ .map(|token| token.to_owned())
+ .collect();
+ ["g", "p", "t", "n", "h", "s", "o", "q"]
+ .iter()
+ .all(|mode_letter| tokens.iter().any(|token| token == mode_letter))
+ })
+}
+
+fn guest_ui_settled(guest_grid: &GridSnapshot) -> bool {
+ let lines = guest_grid.lines();
+ lines
+ .first()
+ .map_or(false, |first_line| first_line.contains("Tab #1"))
+ && last_line_contains(guest_grid, "Ctrl +")
+ && guest_normal_mode_bar_settled(guest_grid)
+}
+
+fn guest_ascended_ui_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| first_line.contains("Tab #1"))
+ && guest_ascended_bar_settled(guest_grid)
+}
+
+fn prompt_count(grid_snapshot: &GridSnapshot) -> usize {
+ grid_snapshot.text.matches('$').count()
+}
+
+fn boot_and_descend_on_first_load(nested: &NestedHarness) {
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.descend_into_guest_via_modal();
+ nested.guest.wait_for_app_load();
+ nested.wait_for_host_to_ping_guest();
+ nested.wait_for_guest_to_reply_to_ping();
+ nested
+ .guest
+ .wait_until("guest settled in normal mode", guest_ui_settled);
+}
+
+fn ascend_via_focus_host_binding(nested: &NestedHarness) {
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ nested.guest.wait_until(
+ "guest settled in normal mode before the mode key is passed through",
+ guest_ui_settled,
+ );
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode after the passed-through mode key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(ASCEND_KEY);
+ nested.wait_for_guest_to_request_host_focus_after(requested);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+}
+
+fn split_host_sibling(nested: &NestedHarness) -> FakePtyHandle {
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the split key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('r'));
+ let sibling = nested.host.expect_pty_spawn();
+ sibling.output(PROMPT);
+ nested.host.wait_until(
+ "host returned to normal mode after the split key",
+ |host_grid| normal_mode_bar_settled(host_grid) && prompt_count(host_grid) >= 1,
+ );
+ sibling
+}
+
+fn descend_into_guest_on_the_left(nested: &NestedHarness) {
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the focus-left key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(ARROW_LEFT);
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.wait_until(
+ "host entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(DESCEND_KEY);
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+}
+
+fn build_descended_state_with_host_sibling(nested: &NestedHarness) -> FakePtyHandle {
+ boot_and_descend_on_first_load(nested);
+ ascend_via_focus_host_binding(nested);
+ let sibling = split_host_sibling(nested);
+ descend_into_guest_on_the_left(nested);
+ sibling
+}
+
+fn quit_guest_then_host(mut nested: NestedHarness) {
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+const SMALLER_SIZE: Size = Size { cols: 90, rows: 18 };
+
+const LARGER_SIZE: Size = Size {
+ cols: 140,
+ rows: 30,
+};
+
+#[test]
+fn resizing_the_terminal_while_descended_keeps_the_guest_sized_to_its_pane() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ let guest_pane_before = nested.host_pane.size().expect("guest pane sized on load");
+ let guest_rows_before = nested.guest.snapshot().row_count();
+
+ nested.host.resize(SMALLER_SIZE);
+ let guest_pane_smaller = nested.host_pane.wait_for_size(
+ "the guest pane shrinks with the host terminal while descended",
+ move |cols, rows| (cols, rows) != guest_pane_before,
+ );
+ assert!(
+ guest_pane_smaller.0 < guest_pane_before.0 || guest_pane_smaller.1 < guest_pane_before.1,
+ "the guest pane must shrink when the host terminal shrinks \
+ (before={guest_pane_before:?}, after={guest_pane_smaller:?})",
+ );
+ let guest_after_shrink = nested.guest.wait_until(
+ "the guest re-renders at the smaller pane size while staying descended",
+ |guest_grid| {
+ guest_grid.row_count() == guest_pane_smaller.1 as usize && guest_grid.tab_bar_appears()
+ },
+ );
+ assert!(
+ guest_after_shrink.row_count() < guest_rows_before,
+ "the descended guest must render fewer rows after the host shrinks",
+ );
+
+ nested.host.resize(LARGER_SIZE);
+ let guest_pane_larger = nested.host_pane.wait_for_size(
+ "the guest pane grows with the host terminal while descended",
+ move |cols, rows| (cols, rows) != guest_pane_smaller,
+ );
+ nested.guest.wait_until(
+ "the guest re-renders at the larger pane size while staying descended",
+ |guest_grid| {
+ guest_grid.row_count() == guest_pane_larger.1 as usize && guest_ui_settled(guest_grid)
+ },
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "a key after the resize still reaches the descended guest",
+ |guest_grid| guest_grid.contains("Pane #2") && guest_grid.contains("Pane #1"),
+ );
+ assert_eq!(
+ nested.focus_lost_count(),
+ 0,
+ "resizing while descended must not ascend the host out of the guest",
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn resizing_the_terminal_while_nested_fullscreen_keeps_the_guest_filling_the_display() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ let entered = nested.mark_guest_to_host();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the fullscreen bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+ nested.guest_to_host().wait_for_after(
+ entered,
+ "the descended guest asks its host to fullscreen its pane",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true }
+ )
+ },
+ );
+
+ let fullscreen_pane_before = nested.host.wait_until(
+ "the guest fills the whole host display before the resize",
+ |host_grid| {
+ has_breadcrumb(host_grid)
+ && host_grid.contains("Tab #1")
+ && host_grid.row_count() == TERMINAL_SIZE.rows
+ },
+ );
+ let host_rows_before = fullscreen_pane_before.row_count();
+
+ nested.host.resize(LARGER_SIZE);
+ let host_after_resize = nested.host.wait_until(
+ "the fullscreened guest still fills the whole enlarged host display",
+ |host_grid| {
+ host_grid.row_count() == LARGER_SIZE.rows
+ && has_breadcrumb(host_grid)
+ && host_grid.contains("Tab #1")
+ },
+ );
+ assert!(
+ host_after_resize.row_count() > host_rows_before,
+ "the host display must render more rows after the terminal grows",
+ );
+ assert!(
+ host_after_resize.row_of_line("Pane #1").is_none(),
+ "no stray host pane-title line while the guest is fullscreened after resize",
+ );
+ nested.guest.wait_until(
+ "the guest re-renders filling the enlarged display while fullscreened",
+ |guest_grid| guest_grid.row_count() == LARGER_SIZE.rows && has_breadcrumb(guest_grid),
+ );
+
+ let exited = nested.mark_guest_to_host();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode before the toggle-off bind",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('f'));
+ nested.guest_to_host().wait_for_after(
+ exited,
+ "the guest asks its host to exit fullscreen",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: false }
+ )
+ },
+ );
+ nested.host.wait_until(
+ "the host chrome returns at the enlarged size after exiting fullscreen",
+ |host_grid| {
+ host_grid.row_count() == LARGER_SIZE.rows
+ && !has_breadcrumb(host_grid)
+ && host_grid.tab_bar_appears()
+ && host_grid.status_bar_appears()
+ },
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn host_scrollback_on_a_guest_pane_scrolls_the_host_not_the_guest() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+
+ nested.host.send_stdin(&keys::ctrl('s'));
+ nested.host.wait_until(
+ "the host enters scroll mode over the guest pane",
+ scroll_mode_bar_settled,
+ );
+
+ let focus_gained_before_scroll = nested.focus_gained_count();
+ nested.host.send_stdin(&keys::key('k'));
+ nested.host.send_stdin(&keys::key('k'));
+ nested.host.wait_until(
+ "the host stays in scroll mode after scrolling the guest pane",
+ scroll_mode_bar_settled,
+ );
+ assert_eq!(
+ nested.focus_gained_count(),
+ focus_gained_before_scroll,
+ "scrolling the host over a guest pane must not descend into the guest",
+ );
+
+ nested.host.send_stdin(&keys::ESC);
+ let host_back_to_normal = nested.host.wait_until(
+ "the host returns to normal mode after scrolling the guest pane",
+ |host_grid| normal_mode_bar_settled(host_grid),
+ );
+ nested.guest.wait_until(
+ "the guest still shows its ascended UI undisturbed by the host scrolling its pane",
+ guest_ascended_ui_settled,
+ );
+ assert!(normal_mode_bar_settled(&host_back_to_normal));
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn host_search_over_a_guest_pane_stays_host_side() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+
+ nested.host.send_stdin(&keys::ctrl('s'));
+ nested.host.wait_until(
+ "the host enters scroll mode over the guest pane",
+ scroll_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('s'));
+ nested.host.wait_until(
+ "the host enters search-input over the guest pane",
+ entering_search_bar_settled,
+ );
+
+ let focus_gained_before_search = nested.focus_gained_count();
+ nested.host.send_stdin(b"Tab");
+ nested.host.send_stdin(&keys::ESC);
+ nested.host.wait_until(
+ "the host settles back in scroll mode after the search input",
+ scroll_mode_bar_settled,
+ );
+ assert_eq!(
+ nested.focus_gained_count(),
+ focus_gained_before_search,
+ "searching the host over a guest pane must not descend into the guest",
+ );
+
+ nested.host.send_stdin(&keys::ESC);
+ nested.host.wait_until(
+ "the host returns to normal mode after searching the guest pane",
+ |host_grid| normal_mode_bar_settled(host_grid),
+ );
+ nested.guest.wait_until(
+ "the guest still shows its ascended UI undisturbed by the host searching its pane",
+ guest_ascended_ui_settled,
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn closing_a_host_sibling_pane_while_descended_keeps_the_guest_live() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ let sibling = build_descended_state_with_host_sibling(&nested);
+ nested.host.wait_until(
+ "the host shows its guest pane and its sibling before the sibling closes",
+ |host_grid| host_grid.contains("Pane #2"),
+ );
+ nested.guest.wait_until(
+ "the guest is settled while the host is descended into it",
+ guest_ui_settled,
+ );
+
+ sibling.exit(Some(0));
+ nested.host.wait_until(
+ "the host closed its exited sibling, leaving only the guest pane",
+ |host_grid| !host_grid.contains("Pane #2"),
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "the guest is still live and reacts to keys after the host sibling closed",
+ |guest_grid| guest_grid.contains("Pane #2") && guest_grid.contains("│"),
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn closing_an_inner_guest_pane_while_descended_keeps_the_guest_live() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_second_pane = nested.guest.expect_pty_spawn();
+ guest_second_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "the guest opened a second inner pane from the passed-through key",
+ |guest_grid| {
+ guest_grid.contains("Pane #1")
+ && guest_grid.contains("Pane #2")
+ && guest_ui_settled(guest_grid)
+ },
+ );
+
+ guest_second_pane.exit(Some(0));
+ nested.guest.wait_until(
+ "the guest closed its inner pane and returned to a single pane",
+ |guest_grid| {
+ !guest_grid.contains("Pane #2")
+ && guest_grid.tab_bar_appears()
+ && guest_grid.cursor.is_some()
+ },
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "the guest is still descended-into and reacts to keys after closing an inner pane",
+ |guest_grid| guest_grid.contains("Pane #2") && guest_grid.contains("│"),
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn detaching_the_guest_client_while_descended_returns_control_to_the_host() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+
+ nested.guest.detach_main_client();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+
+ let sibling = split_host_sibling(&nested);
+ sibling.output(PROMPT);
+ nested.host.wait_until(
+ "the host acts on its own keybindings again after the guest detached",
+ |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid),
+ );
+
+ nested.host.quit();
+}
+
+#[test]
+fn opening_the_host_session_manager_with_a_guest_pane_does_not_misbehave() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.wait_until(
+ "the host entered session mode before launching the session manager",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('w'));
+ nested.host.wait_until(
+ "the host session manager opened over its guest pane",
+ |host_grid| host_grid.contains("Session Manager") && host_grid.status_bar_appears(),
+ );
+
+ nested.host.send_stdin(&keys::ctrl('c'));
+ nested.host.wait_until(
+ "the host session manager closed and normal chrome returned",
+ |host_grid| !host_grid.contains("Session Manager") && normal_mode_bar_settled(host_grid),
+ );
+
+ nested.guest.wait_until(
+ "the guest is undisturbed by the host session manager",
+ guest_ascended_ui_settled,
+ );
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ nested.host.wait_until(
+ "the host still acts on its own keybindings after the session manager closed",
+ |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid),
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
diff --git a/zellij-integration-tests/tests/nested_session_handling_config.rs b/zellij-integration-tests/tests/nested_session_handling_config.rs
new file mode 100644
index 00000000..7e7a8705
--- /dev/null
+++ b/zellij-integration-tests/tests/nested_session_handling_config.rs
@@ -0,0 +1,127 @@
+#![cfg(unix)]
+
+use zellij_integration_tests::{keys, GridSnapshot, NestedHarness, PROMPT, TERMINAL_SIZE};
+
+fn modal_visible(grid_snapshot: &GridSnapshot) -> bool {
+ grid_snapshot.contains("Nested Zellij session detected")
+}
+
+fn descend_config() -> &'static str {
+ r#"nested_session_handling "descend""#
+}
+
+fn fullscreen_config() -> &'static str {
+ r#"nested_session_handling "fullscreen""#
+}
+
+fn never_config() -> &'static str {
+ r#"nested_session_handling "never"
+keybinds {
+ normal {
+ bind "Ctrl y" { FocusGuestSession; }
+ }
+}"#
+}
+
+#[test]
+fn descend_mode_auto_descends_without_modal() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, descend_config());
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.wait_for_host_to_descend_into_guest();
+ nested
+ .host
+ .wait_until("no modal is shown in descend mode", |host_grid| {
+ !modal_visible(host_grid)
+ });
+
+ nested.guest.wait_for_app_load();
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a pane from the passed-through key after auto-descend",
+ |guest_grid| guest_grid.contains("Pane #2"),
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn fullscreen_mode_auto_zooms_without_modal() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, fullscreen_config());
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.wait_for_host_to_descend_into_guest();
+ nested
+ .host
+ .wait_until("no modal is shown in fullscreen mode", |host_grid| {
+ !modal_visible(host_grid)
+ });
+
+ nested.guest.wait_for_app_load();
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a pane from the passed-through key after auto-zoom",
+ |guest_grid| guest_grid.contains("Pane #2"),
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn never_mode_shows_no_modal_and_never_descends() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, never_config());
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.guest.wait_for_app_load();
+
+ nested
+ .host
+ .wait_until("no modal is shown in never mode", |host_grid| {
+ !modal_visible(host_grid)
+ });
+ assert_eq!(
+ nested.focus_gained_count(),
+ 0,
+ "never mode must not descend into the guest by itself"
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn never_mode_allows_manual_descend() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, never_config());
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.guest.wait_for_app_load();
+ assert_eq!(nested.focus_gained_count(), 0);
+
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('y'));
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a pane from the passed-through key after manual FocusGuestSession",
+ |guest_grid| guest_grid.contains("Pane #2"),
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
diff --git a/zellij-integration-tests/tests/nested_sessions.rs b/zellij-integration-tests/tests/nested_sessions.rs
new file mode 100644
index 00000000..d2fe8c53
--- /dev/null
+++ b/zellij-integration-tests/tests/nested_sessions.rs
@@ -0,0 +1,1492 @@
+#![cfg(unix)]
+
+use insta::assert_snapshot;
+use zellij_integration_tests::{
+ col, composite_contains_settled_guest_grid, guest_ascended_bar_settled,
+ host_descended_bar_settled, host_descended_bar_with_ascend_keys_settled, keys, normalized,
+ wait_for_settled_composite, GridSnapshot, NestedDepthThreeHarness, NestedHarness, PROMPT,
+ TERMINAL_SIZE,
+};
+use zellij_utils::data::Direction;
+use zellij_utils::nested_session::NestedSessionMessage;
+
+const ARROW_UP: &[u8] = b"\x1b[A";
+const ARROW_LEFT: &[u8] = b"\x1b[D";
+const ARROW_RIGHT: &[u8] = b"\x1b[C";
+const ARROW_DOWN: &[u8] = b"\x1b[B";
+const ASCEND_KEY: &[u8] = b"]";
+const DESCEND_KEY: &[u8] = b"[";
+const ALT_ARROW_RIGHT: &[u8] = b"\x1b[1;3C";
+
+fn sgr_mouse_report(column: usize, line: usize, button: u8) -> Vec<u8> {
+ format!("\u{1b}[<{};{};{}M", button, column, line).into_bytes()
+}
+
+fn prompt_count(grid_snapshot: &GridSnapshot) -> usize {
+ grid_snapshot.text.matches('$').count()
+}
+
+fn session_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "SESSION") && last_line_contains(grid_snapshot, "Detach")
+}
+
+fn last_line_contains(grid_snapshot: &GridSnapshot, needle: &str) -> bool {
+ grid_snapshot
+ .lines()
+ .last()
+ .map_or(false, |last_line| last_line.contains(needle))
+}
+
+fn normal_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "LOCK")
+}
+
+fn pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "Fullscreen")
+}
+
+fn guest_pane_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "PANE") && last_line_contains(grid_snapshot, "Move")
+}
+
+fn guest_tab_mode_bar_settled(grid_snapshot: &GridSnapshot) -> bool {
+ last_line_contains(grid_snapshot, "TAB") && last_line_contains(grid_snapshot, "New")
+}
+
+fn guest_normal_mode_bar_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_grid.lines().last().map_or(false, |last_line| {
+ if last_line.contains("LOCK") {
+ return true;
+ }
+ let tokens: Vec<String> = last_line
+ .replace('\u{e0b0}', " ")
+ .split_whitespace()
+ .map(|token| token.to_owned())
+ .collect();
+ ["g", "p", "t", "n", "h", "s", "o", "q"]
+ .iter()
+ .all(|mode_letter| tokens.iter().any(|token| token == mode_letter))
+ })
+}
+
+fn guest_ui_settled(guest_grid: &GridSnapshot) -> bool {
+ let lines = guest_grid.lines();
+ lines
+ .first()
+ .map_or(false, |first_line| first_line.contains("Tab #1"))
+ && last_line_contains(guest_grid, "Ctrl +")
+ && guest_normal_mode_bar_settled(guest_grid)
+}
+
+fn single_blank_pane_guest_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_ui_settled(guest_grid) && guest_grid.cursor_is_at(col(0).row(1))
+}
+
+fn guest_ascended_ui_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| first_line.contains("Tab #1"))
+ && guest_ascended_bar_settled(guest_grid)
+}
+
+fn ascended_single_blank_pane_guest_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_ascended_ui_settled(guest_grid) && prompt_count(guest_grid) == 0
+}
+
+fn ascended_two_pane_guest_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_ascended_ui_settled(guest_grid)
+ && guest_grid.contains("Pane #1")
+ && guest_grid.contains("Pane #2")
+ && prompt_count(guest_grid) == 1
+}
+
+fn two_pane_guest_settled(guest_grid: &GridSnapshot) -> bool {
+ guest_ui_settled(guest_grid)
+ && guest_grid.contains("Pane #1")
+ && guest_grid.contains("Pane #2")
+ && prompt_count(guest_grid) == 1
+ && guest_grid.cursor.is_some()
+}
+
+fn host_own_tab_bar_settled(grid_snapshot: &GridSnapshot, guest_session_name: &str) -> bool {
+ let lines = grid_snapshot.lines();
+ let first_row_is_host_tab_bar = lines.first().map_or(false, |first_line| {
+ first_line.contains("Tab #1") && !first_line.contains(guest_session_name)
+ });
+ let guest_pane_title_row_present = lines.get(1).map_or(false, |second_line| {
+ second_line.contains(guest_session_name)
+ });
+ first_row_is_host_tab_bar && guest_pane_title_row_present
+}
+
+fn host_tab_bar_renamed_to_guest(grid_snapshot: &GridSnapshot, guest_session_name: &str) -> bool {
+ grid_snapshot
+ .lines()
+ .first()
+ .map_or(false, |first_line| first_line.contains(guest_session_name))
+}
+
+fn host_chrome_dimmed(host_grid: &GridSnapshot) -> bool {
+ host_grid.line_has_dim("Pane #2") && host_grid.char_dim_of("test-").map_or(false, |dim| dim)
+}
+
+fn host_chrome_undimmed(host_grid: &GridSnapshot) -> bool {
+ !host_grid.line_has_dim("Pane #2") && host_grid.char_dim_of("test-").map_or(true, |dim| !dim)
+}
+
+#[test]
+fn a_guest_zellij_running_inside_a_host_zellij_pane_introduces_itself_and_is_kept_alive() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.descend_into_guest_via_modal();
+ nested.wait_for_host_to_ping_guest();
+ nested.wait_for_guest_to_reply_to_ping();
+
+ nested.guest.wait_for_app_load();
+ nested.host.wait_for_app_load();
+
+ nested.guest.quit();
+ nested.wait_for_host_to_release_guest_focus();
+ nested.host.quit();
+}
+
+#[test]
+fn a_host_zellij_stops_pinging_a_guest_that_freezes_inside_one_of_its_panes() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.wait_for_host_to_ping_guest();
+
+ nested.freeze_guest();
+ nested.assert_host_stops_pinging_frozen_guest();
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+fn boot_and_descend_on_first_load(nested: &NestedHarness) {
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.descend_into_guest_via_modal();
+ nested.guest.wait_for_app_load();
+ nested.wait_for_host_to_ping_guest();
+ nested.wait_for_guest_to_reply_to_ping();
+}
+
+fn ascend_via_focus_host_binding(nested: &NestedHarness) {
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ nested.guest.wait_until(
+ "guest settled in normal mode before the mode key is passed through",
+ guest_ui_settled,
+ );
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.guest.wait_until(
+ "guest entered session mode after the passed-through mode key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(ASCEND_KEY);
+ nested.wait_for_guest_to_request_host_focus_after(requested);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+}
+
+fn split_host_sibling(nested: &NestedHarness) {
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the split key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('r'));
+ let sibling = nested.host.expect_pty_spawn();
+ sibling.output(PROMPT);
+ nested.host.wait_until(
+ "host returned to normal mode after the split key",
+ |host_grid| normal_mode_bar_settled(host_grid) && prompt_count(host_grid) >= 1,
+ );
+}
+
+fn descend_into_guest_on_the_left(nested: &NestedHarness) {
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the focus-left key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(ARROW_LEFT);
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.wait_until(
+ "host entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(DESCEND_KEY);
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+}
+
+fn build_descended_state_with_host_sibling(nested: &NestedHarness) {
+ boot_and_descend_on_first_load(nested);
+ ascend_via_focus_host_binding(nested);
+ split_host_sibling(nested);
+ descend_into_guest_on_the_left(nested);
+}
+
+fn split_host_sibling_below(nested: &NestedHarness) {
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the split-down key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('d'));
+ let sibling = nested.host.expect_pty_spawn();
+ sibling.output(PROMPT);
+ nested.host.wait_until(
+ "host returned to normal mode after the split-down key",
+ |host_grid| normal_mode_bar_settled(host_grid) && prompt_count(host_grid) >= 1,
+ );
+}
+
+fn descend_into_guest_above(nested: &NestedHarness) {
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before the focus-up key",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(ARROW_UP);
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.wait_until(
+ "host entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(DESCEND_KEY);
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+}
+
+fn build_descended_state_with_host_sibling_below(nested: &NestedHarness) {
+ boot_and_descend_on_first_load(nested);
+ ascend_via_focus_host_binding(nested);
+ split_host_sibling_below(nested);
+ descend_into_guest_above(nested);
+}
+
+fn quit_guest_then_host(mut nested: NestedHarness) {
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn host_keys_act_on_host_until_focus_enters_the_guest_then_route_to_guest() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+
+ split_host_sibling(&nested);
+ let host_focused_on_sibling = nested.wait_until_host_composites_settled_guest(
+ "host acted on its own split keybind and created a sibling pane",
+ ascended_single_blank_pane_guest_settled,
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #2")
+ && prompt_count(host_grid) == 1
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_keys_act_on_host_before_descend",
+ normalized(&host_focused_on_sibling)
+ );
+
+ descend_into_guest_on_the_left(&nested);
+ let host_descended = nested.wait_until_host_composites_settled_guest(
+ "host descended into the guest pane, showing the guest nested inside",
+ single_blank_pane_guest_settled,
+ |host_grid| {
+ host_descended_bar_settled(host_grid)
+ && host_grid.contains("Pane #2")
+ && prompt_count(host_grid) == 1
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!("host_grid_while_descended", normalized(&host_descended));
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ let guest_after_new_pane = nested.guest.wait_until(
+ "guest spawned a second pane from the passed-through key",
+ two_pane_guest_settled,
+ );
+ assert_snapshot!(
+ "guest_reacts_to_passed_through_new_pane_key",
+ normalized(&guest_after_new_pane)
+ );
+
+ let host_pane_count_unchanged = nested.wait_until_host_composites_settled_guest(
+ "host still shows exactly its own two panes while the guest gained an inner pane",
+ two_pane_guest_settled,
+ |host_grid| {
+ host_descended_bar_settled(host_grid)
+ && host_grid.contains("Pane #2")
+ && !host_grid.contains("Pane #3")
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_pane_count_unchanged_while_descended",
+ normalized(&host_pane_count_unchanged)
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn focus_host_session_binding_returns_control_to_host() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.send_stdin(ASCEND_KEY);
+ nested.wait_for_guest_to_request_host_focus_after(requested);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ let host_ascended = nested.wait_until_host_composites_settled_guest(
+ "host took control back from the guest",
+ ascended_single_blank_pane_guest_settled,
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #2")
+ && prompt_count(host_grid) == 1
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_ascended_after_focus_host_binding",
+ normalized(&host_ascended)
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ let host_after_new_pane = nested.wait_until_host_composites_settled_guest(
+ "host acted on its own key after ascending",
+ ascended_single_blank_pane_guest_settled,
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #3")
+ && prompt_count(host_grid) == 2
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_acts_on_key_after_ascend",
+ normalized(&host_after_new_pane)
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn clicking_a_host_pane_while_descended_refocuses_and_ascends() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, "mouse_mode true");
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let ascended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&sgr_mouse_report(90, 8, 0));
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ let host_after_click = nested.wait_until_host_composites_settled_guest(
+ "host focus moved onto the clicked host pane",
+ ascended_single_blank_pane_guest_settled,
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #2")
+ && prompt_count(host_grid) == 1
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_ascended_after_mouse_click",
+ normalized(&host_after_click)
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ let host_after_new_pane = nested.wait_until_host_composites_settled_guest(
+ "host acted on its own key after the click ascended it",
+ ascended_single_blank_pane_guest_settled,
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #3")
+ && prompt_count(host_grid) == 2
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_acts_on_key_after_mouse_ascend",
+ normalized(&host_after_new_pane)
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+fn sgr_left_click(column: usize, line: usize) -> Vec<u8> {
+ format!(
+ "\u{1b}[<0;{};{}M\u{1b}[<0;{};{}m",
+ column, line, column, line
+ )
+ .into_bytes()
+}
+
+fn display_column_of(line: &str, needle: &str) -> Option<usize> {
+ line.find(needle)
+ .map(|byte_offset| line[..byte_offset].chars().count())
+}
+
+#[test]
+fn clicking_the_host_new_tab_button_while_descended_ascends_and_keeps_working() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, "mouse_mode true");
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ nested.guest.wait_for_app_load();
+
+ let host_before = nested.host.wait_until(
+ "the host tab bar shows the new tab button while descended",
+ |host_grid| {
+ host_grid
+ .lines()
+ .first()
+ .is_some_and(|tab_bar| tab_bar.contains('+'))
+ },
+ );
+ let tab_bar = host_before.lines().first().cloned().unwrap();
+ let new_tab_button_column =
+ display_column_of(&tab_bar, "+").expect("new tab button is on the host tab bar") + 1;
+
+ let ascended = nested.mark_host_to_guest();
+ nested
+ .host
+ .send_stdin(&sgr_left_click(new_tab_button_column, 1));
+ let host_new_tab_pane = nested.host.expect_pty_spawn();
+ host_new_tab_pane.output(PROMPT);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ let host_after_new_tab = nested.host.wait_until(
+ "the host opened a second tab and undimmed after clicking the new tab button",
+ |host_grid| {
+ host_grid.lines().first().map_or(false, |tab_bar| {
+ tab_bar.contains("Tab #1") && tab_bar.contains("Tab #2")
+ }) && normal_mode_bar_settled(host_grid)
+ },
+ );
+ assert_snapshot!(
+ "host_ascended_after_clicking_new_tab_button",
+ normalized(&host_after_new_tab)
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ nested.host.wait_until(
+ "the host acts on its own key after clicking the new tab button ascended it",
+ |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid),
+ );
+
+ let _ = guest_session_name;
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn clicking_a_host_tab_while_descended_switches_tabs_and_ascends() {
+ let mut nested = NestedHarness::start_with_host_config(TERMINAL_SIZE, "mouse_mode true");
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ nested.guest.wait_for_app_load();
+
+ let host_before = nested.host.wait_until(
+ "the host tab bar shows the new tab button while descended",
+ |host_grid| {
+ host_grid
+ .lines()
+ .first()
+ .is_some_and(|tab_bar| tab_bar.contains('+'))
+ },
+ );
+ let tab_bar = host_before.lines().first().cloned().unwrap();
+ let new_tab_button_column =
+ display_column_of(&tab_bar, "+").expect("new tab button is on the host tab bar") + 1;
+
+ let ascended_by_new_tab = nested.mark_host_to_guest();
+ nested
+ .host
+ .send_stdin(&sgr_left_click(new_tab_button_column, 1));
+ let host_new_tab_pane = nested.host.expect_pty_spawn();
+ host_new_tab_pane.output(PROMPT);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended_by_new_tab);
+ let host_two_tabs = nested.host.wait_until(
+ "the host now has two tabs with the second one focused on a host pane",
+ |host_grid| {
+ host_grid.lines().first().map_or(false, |tab_bar| {
+ tab_bar.contains("Tab #1") && tab_bar.contains("Tab #2")
+ }) && normal_mode_bar_settled(host_grid)
+ },
+ );
+ let first_tab_column = host_two_tabs
+ .lines()
+ .first()
+ .and_then(|tab_bar| display_column_of(tab_bar, "Tab #1"))
+ .expect("the first host tab ribbon is on the host tab bar")
+ + 1;
+ let second_tab_column = host_two_tabs
+ .lines()
+ .first()
+ .and_then(|tab_bar| display_column_of(tab_bar, "Tab #2"))
+ .expect("the second host tab ribbon is on the host tab bar")
+ + 1;
+
+ let descended_by_tab_one = nested.mark_host_to_guest();
+ nested.host.send_stdin(&sgr_left_click(first_tab_column, 1));
+ nested.wait_for_host_to_descend_into_guest_after(descended_by_tab_one);
+
+ let ascended_by_tab_two = nested.mark_host_to_guest();
+ nested
+ .host
+ .send_stdin(&sgr_left_click(second_tab_column, 1));
+ nested.wait_for_host_to_ascend_from_guest_after(ascended_by_tab_two);
+ nested.host.wait_until(
+ "clicking the second host tab switches away from the guest tab in normal mode",
+ |host_grid| {
+ host_grid.lines().first().map_or(false, |tab_bar| {
+ tab_bar.contains("Tab #1") && tab_bar.contains("Tab #2")
+ }) && normal_mode_bar_settled(host_grid)
+ },
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ nested.host.wait_until(
+ "the host acts on its own key after the tab click ascended it",
+ |host_grid| host_grid.contains("Pane #2") && normal_mode_bar_settled(host_grid),
+ );
+
+ let _ = guest_session_name;
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn a_key_immediately_after_descend_lands_in_the_guest_and_after_ascend_lands_in_the_host() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+ split_host_sibling(&nested);
+
+ descend_into_guest_on_the_left(&nested);
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ let guest_end_state = nested.guest.wait_until(
+ "key immediately after descend landed in the guest",
+ two_pane_guest_settled,
+ );
+ assert_snapshot!(
+ "key_after_descend_lands_in_guest",
+ normalized(&guest_end_state)
+ );
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.send_stdin(ASCEND_KEY);
+ nested.wait_for_guest_to_request_host_focus_after(requested);
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ let host_end_state = nested.wait_until_host_composites_settled_guest(
+ "key immediately after ascend landed in the host",
+ ascended_two_pane_guest_settled,
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #3")
+ && prompt_count(host_grid) == 3
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "key_after_ascend_lands_in_host",
+ normalized(&host_end_state)
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn descend_on_first_load_when_guest_pane_is_already_focused() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.descend_into_guest_via_modal();
+ assert_eq!(
+ nested.focus_gained_count(),
+ 1,
+ "the host should descend exactly once after the modal is answered without any focus change"
+ );
+ assert_eq!(
+ nested.focus_lost_count(),
+ 0,
+ "the host should not ascend on first load"
+ );
+
+ nested.guest.wait_for_app_load();
+ let host_descended_on_load = nested.wait_until_host_composites_settled_guest(
+ "host descended into the already-focused guest pane at load",
+ single_blank_pane_guest_settled,
+ |host_grid| {
+ host_descended_bar_with_ascend_keys_settled(host_grid)
+ && host_tab_bar_renamed_to_guest(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_descended_on_first_load",
+ normalized(&host_descended_on_load)
+ );
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ let guest_after_new_pane = nested.guest.wait_until(
+ "guest reacted to the passed-through key after a first-load descend",
+ two_pane_guest_settled,
+ );
+ assert_snapshot!(
+ "guest_reacts_after_first_load_descend",
+ normalized(&guest_after_new_pane)
+ );
+
+ nested.guest.quit();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+ nested.host.quit();
+}
+
+#[test]
+fn guest_going_away_clears_passthrough() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ build_descended_state_with_host_sibling(&nested);
+ nested.wait_until_host_composites_settled_guest(
+ "host settled the descended guest view before the guest froze",
+ single_blank_pane_guest_settled,
+ |host_grid| {
+ host_descended_bar_settled(host_grid)
+ && host_grid.contains("Pane #2")
+ && prompt_count(host_grid) == 1
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+
+ nested.freeze_guest();
+ nested.assert_host_stops_pinging_frozen_guest();
+ nested.wait_for_host_to_reclaim_focus_after_guest_exit();
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ let host_after_guest_gone = nested.host.wait_until(
+ "host acts on its own key after the guest went away",
+ |host_grid| {
+ normal_mode_bar_settled(host_grid)
+ && host_grid.contains("Pane #3")
+ && prompt_count(host_grid) == 2
+ && host_grid.cursor.is_some()
+ && host_own_tab_bar_settled(host_grid, &guest_session_name)
+ },
+ );
+ assert_snapshot!(
+ "host_acts_after_guest_cleared_passthrough",
+ normalized(&host_after_guest_gone)
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn descend_and_ascend_work_at_depth_three() {
+ let mut nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE);
+ let inner_session_name = nested.inner.session_name().to_string();
+
+ nested.boot_and_descend_depth_three();
+ nested.inner.wait_for_app_load();
+
+ let middle_descended_into_settled_inner = |middle_grid: &GridSnapshot| {
+ let inner_grid = nested.inner.snapshot();
+ single_blank_pane_guest_settled(&inner_grid)
+ && host_descended_bar_settled(middle_grid)
+ && middle_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| first_line.contains(&inner_session_name))
+ && composite_contains_settled_guest_grid(middle_grid, &inner_grid)
+ };
+ let middle_ascended_from_settled_inner = |middle_grid: &GridSnapshot| {
+ let inner_grid = nested.inner.snapshot();
+ ascended_single_blank_pane_guest_settled(&inner_grid)
+ && normal_mode_bar_settled(middle_grid)
+ && middle_grid
+ .lines()
+ .first()
+ .map_or(false, |first_line| first_line.contains(&inner_session_name))
+ && composite_contains_settled_guest_grid(middle_grid, &inner_grid)
+ };
+ let settled_outer_tab_title = format!("{} | {}", inner_session_name, inner_session_name);
+ let outer_settled = |outer_grid: &GridSnapshot| {
+ host_descended_bar_settled(outer_grid)
+ && outer_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains(&settled_outer_tab_title)
+ })
+ };
+
+ let outer_doubly_nested = wait_for_settled_composite(
+ &nested.outer,
+ &nested.middle,
+ "outer host shows the doubly-nested descended UI",
+ &middle_descended_into_settled_inner,
+ &outer_settled,
+ );
+ assert_snapshot!(
+ "outer_grid_doubly_nested_descended",
+ normalized(&outer_doubly_nested)
+ );
+
+ let requested = nested.mark_inner_to_middle();
+ let ascended = nested.mark_middle_to_inner();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.inner.wait_until(
+ "inner entered session mode after the passed-through mode key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ASCEND_KEY);
+ nested.wait_for_inner_to_request_host_focus_after(requested);
+ nested.wait_for_middle_to_ascend_from_inner_after(ascended);
+
+ let outer_after_inner_ascend = wait_for_settled_composite(
+ &nested.outer,
+ &nested.middle,
+ "middle ascended out of the inner while outer stays descended into middle",
+ &middle_ascended_from_settled_inner,
+ &outer_settled,
+ );
+ assert_snapshot!(
+ "outer_grid_after_inner_ascend",
+ normalized(&outer_after_inner_ascend)
+ );
+
+ nested.inner.quit();
+
+ nested.middle.quit();
+ nested.wait_for_outer_to_reclaim_focus_after_middle_exit();
+
+ nested.outer.quit();
+}
+
+fn pass_ctrl_p_to_guest(nested: &NestedHarness) {
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.guest.wait_until(
+ "guest entered pane mode after the passed-through mode key",
+ guest_pane_mode_bar_settled,
+ );
+}
+
+fn split_second_guest_pane(nested: &NestedHarness) {
+ nested.host.send_stdin(&keys::alt('n'));
+ let guest_new_pane = nested.guest.expect_pty_spawn();
+ guest_new_pane.output(PROMPT);
+ nested.guest.wait_until(
+ "guest spawned a second pane from the passed-through key",
+ two_pane_guest_settled,
+ );
+}
+
+#[test]
+fn descended_client_sees_dimmed_host_chrome_while_second_client_does_not() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+ split_host_sibling(&nested);
+
+ let second = nested.host.attach_client(TERMINAL_SIZE);
+ let second_client = second.wait_until(
+ "the second host client focuses the sibling pane undimmed on attach",
+ |host_grid| {
+ host_own_tab_bar_settled(host_grid, &guest_session_name)
+ && host_grid.contains("Pane #2")
+ && host_chrome_undimmed(host_grid)
+ },
+ );
+
+ let descended = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "first client entered pane mode before moving focus onto the guest",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(ARROW_LEFT);
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.wait_until(
+ "first client entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.host.send_stdin(DESCEND_KEY);
+ nested.wait_for_host_to_descend_into_guest_after(descended);
+
+ let descended_client = nested.host.wait_until(
+ "the first host client renders dimmed host chrome while descended",
+ |host_grid| {
+ host_grid.contains("Pane #2")
+ && host_grid.contains("Tab #1")
+ && host_chrome_dimmed(host_grid)
+ },
+ );
+
+ let second_still_undimmed = second.wait_until(
+ "the second host client stays undimmed while the first is descended",
+ |host_grid| host_grid.contains("Pane #2") && host_chrome_undimmed(host_grid),
+ );
+
+ assert!(host_chrome_dimmed(&descended_client));
+ assert!(host_chrome_undimmed(&second_client));
+ assert!(host_chrome_undimmed(&second_still_undimmed));
+
+ second.quit();
+ quit_guest_then_host(nested);
+}
+
+fn host_chrome_rows(host_grid: &GridSnapshot) -> (String, String) {
+ let lines = host_grid.lines();
+ let first = lines.first().cloned().unwrap_or_default();
+ let last = lines.last().cloned().unwrap_or_default();
+ (first, last)
+}
+
+#[test]
+fn host_chrome_restores_exactly_after_ascend() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let guest_session_name = nested.guest.session_name().to_string();
+
+ boot_and_descend_on_first_load(&nested);
+ ascend_via_focus_host_binding(&nested);
+ split_host_sibling(&nested);
+
+ let before_descend =
+ nested
+ .host
+ .wait_until("host chrome is undimmed before descending", |host_grid| {
+ host_own_tab_bar_settled(host_grid, &guest_session_name)
+ && host_grid.contains("Pane #2")
+ && normal_mode_bar_settled(host_grid)
+ && host_chrome_undimmed(host_grid)
+ });
+ let before_chrome = host_chrome_rows(&before_descend);
+
+ descend_into_guest_on_the_left(&nested);
+ nested
+ .host
+ .wait_until("host chrome dims while descended", |host_grid| {
+ host_own_tab_bar_settled(host_grid, &guest_session_name)
+ && host_grid.contains("Pane #2")
+ && host_chrome_dimmed(host_grid)
+ });
+
+ let released = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('o'));
+ nested.host.send_stdin(ASCEND_KEY);
+ nested.wait_for_host_to_ascend_from_guest_after(released);
+ nested.host.send_stdin(&keys::ESC);
+ let after_ascend = nested.host.wait_until(
+ "host chrome restores to undimmed after ascending",
+ |host_grid| {
+ host_grid.contains("Pane #2")
+ && normal_mode_bar_settled(host_grid)
+ && host_chrome_undimmed(host_grid)
+ && host_chrome_rows(host_grid) == before_chrome
+ },
+ );
+
+ assert_eq!(
+ host_chrome_rows(&after_ascend),
+ before_chrome,
+ "host chrome rows restore exactly after ascend"
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn guest_edge_focus_move_bubbles_out_to_the_adjacent_host_pane() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ pass_ctrl_p_to_guest(&nested);
+ nested.host.send_stdin(ARROW_RIGHT);
+ nested.guest_to_host().wait_for_after(
+ requested,
+ "the guest at its right edge bubbles a focus-host request to the right",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FocusHost {
+ direction: Some(Direction::Right)
+ }
+ )
+ },
+ );
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ let host_on_sibling = nested.host.wait_until(
+ "host focus landed on the sibling pane to the right of the guest",
+ |host_grid| {
+ host_grid.contains("Pane #2")
+ && host_grid.contains("Tab #1")
+ && host_chrome_undimmed(host_grid)
+ },
+ );
+ assert!(host_chrome_undimmed(&host_on_sibling));
+
+ nested.host.send_stdin(&keys::alt('n'));
+ let host_new_pane = nested.host.expect_pty_spawn();
+ host_new_pane.output(PROMPT);
+ nested.host.wait_until(
+ "host acts on its own key after the guest bubbled focus out",
+ |host_grid| host_grid.contains("Pane #3") && host_grid.contains("Tab #1"),
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn host_focus_move_onto_guest_pane_enters_at_the_correct_edge() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ pass_ctrl_p_to_guest(&nested);
+ nested.host.send_stdin(ARROW_RIGHT);
+ nested.guest_to_host().wait_for_after(
+ requested,
+ "the guest at its right edge bubbles a focus-host request to the right",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FocusHost {
+ direction: Some(Direction::Right)
+ }
+ )
+ },
+ );
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+
+ let entered = nested.mark_host_to_guest();
+ nested.host.send_stdin(&keys::ctrl('p'));
+ nested.host.wait_until(
+ "host entered pane mode before moving focus onto the guest",
+ pane_mode_bar_settled,
+ );
+ nested.host.send_stdin(ARROW_LEFT);
+ nested.host_to_guest().wait_for_after(
+ entered,
+ "the host tells the guest it entered from the left edge when moving left onto it",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FocusGained {
+ from_direction: Some(Direction::Left)
+ }
+ )
+ },
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn no_bubble_when_the_focus_move_stays_inside_the_guest() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+ split_second_guest_pane(&nested);
+
+ nested.guest.wait_until("two panes", two_pane_guest_settled);
+ pass_ctrl_p_to_guest(&nested);
+ let focus_host_before = nested
+ .guest_to_host()
+ .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. }));
+ nested.host.send_stdin(ARROW_LEFT);
+ nested.guest.wait_until(
+ "guest focus moved to the left pane while staying in pane mode",
+ |guest_grid| {
+ guest_grid.cursor.map_or(false, |cursor| cursor.x < 15)
+ && guest_pane_mode_bar_settled(guest_grid)
+ },
+ );
+ assert_eq!(
+ nested
+ .guest_to_host()
+ .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. })),
+ focus_host_before,
+ "moving focus between two guest panes must not bubble a focus-host request"
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn fullscreened_guest_pane_still_bubbles_at_the_edges() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling_below(&nested);
+ split_second_guest_pane(&nested);
+
+ pass_ctrl_p_to_guest(&nested);
+ nested.host.send_stdin(&keys::key('f'));
+ nested
+ .guest
+ .wait_until("guest fullscreened its focused pane", |guest_grid| {
+ guest_grid.contains("FULLSCREEN") || guest_ui_settled(guest_grid)
+ });
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ pass_ctrl_p_to_guest(&nested);
+ nested.host.send_stdin(ARROW_DOWN);
+ nested.guest_to_host().wait_for_after(
+ requested,
+ "the fullscreened guest bubbles a downward focus-host request off its edge",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FocusHost {
+ direction: Some(Direction::Down)
+ }
+ )
+ },
+ );
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+ nested.host.wait_until(
+ "host focus landed on the pane below the guest",
+ |host_grid| {
+ host_grid.contains("Pane #2")
+ && host_grid.contains("Tab #1")
+ && host_chrome_undimmed(host_grid)
+ },
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn move_focus_or_tab_bubbles_only_when_the_guest_has_a_single_tab() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let requested = nested.mark_guest_to_host();
+ let ascended = nested.mark_host_to_guest();
+ nested.guest.wait_until(
+ "guest settled in normal mode before the alt-arrow is passed through",
+ guest_ui_settled,
+ );
+ nested.host.send_stdin(ALT_ARROW_RIGHT);
+ nested.guest_to_host().wait_for_after(
+ requested,
+ "a single-tab guest bubbles the move-or-tab off its right edge",
+ |message| {
+ matches!(
+ message,
+ NestedSessionMessage::FocusHost {
+ direction: Some(Direction::Right)
+ }
+ )
+ },
+ );
+ nested.wait_for_host_to_ascend_from_guest_after(ascended);
+ nested.host.wait_until(
+ "host focus landed on the sibling after the single-tab move-or-tab bubbled",
+ |host_grid| {
+ host_grid.contains("Pane #2")
+ && host_grid.contains("Tab #1")
+ && host_chrome_undimmed(host_grid)
+ },
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn move_focus_or_tab_wraps_tabs_without_bubbling_when_the_guest_has_multiple_tabs() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+
+ nested.host.send_stdin(&keys::ctrl('t'));
+ nested.guest.wait_until(
+ "guest entered tab mode after the passed-through key",
+ guest_tab_mode_bar_settled,
+ );
+ nested.host.send_stdin(&keys::key('n'));
+ nested
+ .guest
+ .wait_until("guest opened a second tab and focused it", |guest_grid| {
+ guest_grid.contains("Tab #2") && guest_normal_mode_bar_settled(guest_grid)
+ });
+
+ let focus_host_before = nested
+ .guest_to_host()
+ .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. }));
+ nested.host.send_stdin(ALT_ARROW_RIGHT);
+ nested.guest.wait_until(
+ "guest wrapped back to the first tab instead of bubbling",
+ |guest_grid| {
+ guest_grid.lines().first().map_or(false, |first_line| {
+ first_line.contains("Tab #1") && first_line.contains("Tab #2")
+ }) && guest_normal_mode_bar_settled(guest_grid)
+ },
+ );
+ assert_eq!(
+ nested
+ .guest_to_host()
+ .count(|message| matches!(message, NestedSessionMessage::FocusHost { .. })),
+ focus_host_before,
+ "a multi-tab guest wraps tabs and must not bubble a focus-host request"
+ );
+
+ quit_guest_then_host(nested);
+}
+
+#[test]
+fn nested_fullscreen_exit_keeps_hidden_host_floating_panes_hidden() {
+ let nested = NestedDepthThreeHarness::start_depth_three(TERMINAL_SIZE);
+
+ nested.boot_and_descend_depth_three();
+ nested.inner.wait_for_app_load();
+
+ nested.outer.send_stdin(&keys::alt('f'));
+ let inner_float = nested.inner.expect_pty_spawn();
+ inner_float.output(b"INNER-FLOAT ");
+ nested
+ .inner
+ .wait_until("inner floating pane shown", |inner_grid| {
+ inner_grid.contains("INNER-FLOAT")
+ });
+
+ let requested = nested.mark_inner_to_middle();
+ let ascended = nested.mark_middle_to_inner();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.inner.wait_until(
+ "inner entered session mode after the passed-through mode key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ASCEND_KEY);
+ nested.wait_for_inner_to_request_host_focus_after(requested);
+ nested.wait_for_middle_to_ascend_from_inner_after(ascended);
+
+ nested.outer.send_stdin(&keys::alt('f'));
+ let middle_float = nested.middle.expect_pty_spawn();
+ middle_float.output(b"MIDDLE-FLOAT ");
+ nested
+ .middle
+ .wait_until("middle floating pane shown", |middle_grid| {
+ middle_grid.contains("MIDDLE-FLOAT")
+ });
+ nested.outer.send_stdin(&keys::alt('f'));
+ nested
+ .middle
+ .wait_until("middle floating pane hidden", |middle_grid| {
+ !middle_grid.contains("MIDDLE-FLOAT")
+ });
+
+ nested.outer.send_stdin(&keys::ctrl('p'));
+ nested.middle.wait_until(
+ "middle entered pane mode before the split key",
+ pane_mode_bar_settled,
+ );
+ nested.outer.send_stdin(&keys::key('r'));
+ let middle_sibling = nested.middle.expect_pty_spawn();
+ middle_sibling.output(b"MIDDLE-SIBLING ");
+ nested.middle.wait_until(
+ "middle spawned a sibling pane next to the inner guest",
+ |middle_grid| middle_grid.contains("MIDDLE-SIBLING"),
+ );
+
+ let ascended_outer = nested.mark_outer_to_middle();
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.middle.wait_until(
+ "middle entered session mode before the ascend key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ASCEND_KEY);
+ nested.wait_for_outer_to_ascend_from_middle_after(ascended_outer);
+
+ nested.outer.send_stdin(&keys::alt('f'));
+ let outer_float = nested.outer.expect_pty_spawn();
+ outer_float.output(b"OUTER-FLOAT ");
+ nested
+ .outer
+ .wait_until("outer floating pane shown", |outer_grid| {
+ outer_grid.contains("OUTER-FLOAT")
+ });
+ nested.outer.send_stdin(&keys::alt('f'));
+ nested
+ .outer
+ .wait_until("outer floating pane hidden", |outer_grid| {
+ !outer_grid.contains("OUTER-FLOAT")
+ });
+
+ nested.outer.send_stdin(&keys::ctrl('p'));
+ nested.outer.wait_until(
+ "outer entered pane mode before the split key",
+ pane_mode_bar_settled,
+ );
+ nested.outer.send_stdin(&keys::key('r'));
+ let outer_sibling = nested.outer.expect_pty_spawn();
+ outer_sibling.output(b"OUTER-SIBLING ");
+ nested.outer.wait_until(
+ "outer spawned a sibling pane next to the middle guest",
+ |outer_grid| outer_grid.contains("OUTER-SIBLING"),
+ );
+
+ let outer_descended = nested.mark_outer_to_middle();
+ nested.outer.send_stdin(&keys::ctrl('p'));
+ nested.outer.wait_until(
+ "outer entered pane mode before the focus-left key",
+ pane_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ARROW_LEFT);
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.outer.wait_until(
+ "outer entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(DESCEND_KEY);
+ nested.wait_for_outer_to_descend_into_middle_after(outer_descended);
+
+ let middle_descended = nested.mark_middle_to_inner();
+ nested.outer.send_stdin(&keys::ctrl('p'));
+ nested.middle.wait_until(
+ "middle entered pane mode before the focus-left key",
+ guest_pane_mode_bar_settled,
+ );
+ nested.outer.send_stdin(ARROW_LEFT);
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.middle.wait_until(
+ "middle entered session mode before the descend key",
+ session_mode_bar_settled,
+ );
+ nested.outer.send_stdin(DESCEND_KEY);
+ nested.wait_for_middle_to_descend_into_inner_after(middle_descended);
+
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.outer.send_stdin(&keys::key('f'));
+ nested.middle.wait_until(
+ "middle hid its own chrome and sibling while the inner guest is fullscreen",
+ |middle_grid| {
+ middle_grid.contains("INNER-FLOAT")
+ && !middle_grid.contains("MIDDLE-SIBLING")
+ && !middle_grid.contains("MIDDLE-FLOAT")
+ },
+ );
+ nested.outer.wait_until(
+ "outer hid its own chrome and sibling while the nested guest chain is fullscreen",
+ |outer_grid| {
+ outer_grid.contains("INNER-FLOAT")
+ && !outer_grid.contains("OUTER-SIBLING")
+ && !outer_grid.contains("MIDDLE-SIBLING")
+ },
+ );
+
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.outer.send_stdin(&keys::key('f'));
+ nested.middle.wait_until(
+ "middle restored its sibling pane after fullscreen exit",
+ |middle_grid| middle_grid.contains("MIDDLE-SIBLING"),
+ );
+ nested.outer.wait_until(
+ "outer restored its sibling pane after fullscreen exit",
+ |outer_grid| outer_grid.contains("OUTER-SIBLING"),
+ );
+
+ let middle_after_exit = nested.middle.snapshot();
+ let outer_after_exit = nested.outer.snapshot();
+ assert!(
+ !middle_after_exit.contains("MIDDLE-FLOAT") && !outer_after_exit.contains("OUTER-FLOAT"),
+ "hidden host floating panes must stay hidden after nested fullscreen exit\n=== middle grid ===\n{}\n=== outer grid ===\n{}\n=== log tail ===\n{}",
+ middle_after_exit.text,
+ outer_after_exit.text,
+ zellij_integration_tests::test_env::log_tail(400),
+ );
+
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.outer.send_stdin(&keys::key('f'));
+ nested.middle.wait_until(
+ "middle hid its sibling again during the second fullscreen",
+ |middle_grid| !middle_grid.contains("MIDDLE-SIBLING"),
+ );
+ nested.outer.wait_until(
+ "outer hid its sibling again during the second fullscreen",
+ |outer_grid| !outer_grid.contains("OUTER-SIBLING"),
+ );
+
+ nested.outer.send_stdin(&keys::ctrl('o'));
+ nested.outer.send_stdin(&keys::key('f'));
+ nested.middle.wait_until(
+ "middle restored its sibling pane after the second fullscreen exit",
+ |middle_grid| middle_grid.contains("MIDDLE-SIBLING"),
+ );
+ nested.outer.wait_until(
+ "outer restored its sibling pane after the second fullscreen exit",
+ |outer_grid| outer_grid.contains("OUTER-SIBLING"),
+ );
+
+ let middle_after_second_exit = nested.middle.snapshot();
+ let outer_after_second_exit = nested.outer.snapshot();
+ assert!(
+ !middle_after_second_exit.contains("MIDDLE-FLOAT")
+ && !outer_after_second_exit.contains("OUTER-FLOAT"),
+ "hidden host floating panes must stay hidden after a repeated nested fullscreen exit\n=== middle grid ===\n{}\n=== outer grid ===\n{}\n=== log tail ===\n{}",
+ middle_after_second_exit.text,
+ outer_after_second_exit.text,
+ zellij_integration_tests::test_env::log_tail(400),
+ );
+}
+
+fn column_in_last_line(grid: &GridSnapshot, needle: &str) -> Option<(usize, usize)> {
+ let lines = grid.lines();
+ let y = lines.len().checked_sub(1)?;
+ let line = lines.last()?;
+ let byte_index = line.find(needle)?;
+ Some((line[..byte_index].chars().count(), y))
+}
+
+fn descended_hint_present(grid: &GridSnapshot) -> bool {
+ last_line_contains(grid, "Ascend:")
+}
+
+#[test]
+fn descended_host_tab_bar_session_name_is_dimmed() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+ let host_session_name = nested.host.session_name().to_string();
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let descended = nested.host.wait_until(
+ "descended host renders its dimmed tab-bar with the status hint",
+ |host_grid| {
+ host_grid.contains("Pane #2")
+ && host_grid.contains(&format!("({})", host_session_name))
+ && descended_hint_present(host_grid)
+ && host_grid
+ .char_dim_of(&format!("({})", host_session_name))
+ .unwrap_or(false)
+ },
+ );
+
+ assert!(
+ descended
+ .char_dim_of("Zellij")
+ .expect("host tab-bar prefix present"),
+ "the host tab-bar 'Zellij' prefix is dimmed while descended"
+ );
+ assert!(
+ descended
+ .char_dim_of(&format!("({})", host_session_name))
+ .expect("host session name present"),
+ "the host tab-bar session name is dimmed while descended"
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn descended_status_hint_is_dim_italic_with_colored_keys() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let descended = nested.host.wait_until(
+ "descended host shows the nested session status hint",
+ |host_grid| host_grid.contains("Pane #2") && descended_hint_present(host_grid),
+ );
+
+ let (label_column, label_row) = column_in_last_line(&descended, "Ascend:")
+ .expect("the ascend hint label is present on the last line");
+ assert!(
+ descended.char_is_dim(label_column, label_row),
+ "the ascend hint label is dimmed"
+ );
+ assert!(
+ descended.char_is_italic(label_column, label_row),
+ "the ascend hint label is italic"
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
+
+#[test]
+fn nested_pane_frames_never_render_the_guest_choice_indicator() {
+ let mut nested = NestedHarness::start(TERMINAL_SIZE);
+
+ build_descended_state_with_host_sibling(&nested);
+
+ let descended = nested.host.wait_until(
+ "descended host settled with its guest pane and sibling present",
+ |host_grid| host_grid.contains("Pane #2") && descended_hint_present(host_grid),
+ );
+
+ assert!(
+ !descended.contains("NESTED ZELLIJ")
+ && !descended.contains("NESTED AUTO FOCUS")
+ && !descended.contains("NESTED MANUAL FOCUS"),
+ "no guest-choice indicator is rendered in any pane frame while descended\n=== host grid ===\n{}",
+ descended.text
+ );
+
+ nested.guest.quit();
+ nested.host.quit();
+}
diff --git a/zellij-integration-tests/tests/nested_status_hints.rs b/zellij-integration-tests/tests/nested_status_hints.rs
new file mode 100644
index 00000000..29f25989
--- /dev/null
+++ b/zellij-integration-tests/tests/nested_status_hints.rs
@@ -0,0 +1,28 @@
+#![cfg(unix)]
+use zellij_integration_tests::nested::NestedHarness;
+use zellij_utils::nested_session::NestedSessionMessage;
+use zellij_utils::pane_size::Size;
+
+const TERMINAL_SIZE: Size = Size {
+ cols: 121,
+ rows: 30,
+};
+
+#[test]
+fn guest_shows_the_ascended_hint_on_boot() {
+ let nested = NestedHarness::start(TERMINAL_SIZE);
+ nested.wait_for_guest_to_announce();
+ nested.wait_for_host_to_acknowledge_guest();
+ nested.host_to_guest().wait_for(
+ "the host to include its descend shortcut in the announce_ack",
+ |message| matches!(message, NestedSessionMessage::AnnounceAck { descend_keys, .. } if !descend_keys.is_empty()),
+ );
+ nested.guest_to_host().wait_for(
+ "the guest to advertise its ascend shortcut after the handshake",
+ |message| matches!(message, NestedSessionMessage::ShortcutUpdate { ascend_keys, .. } if !ascend_keys.is_empty()),
+ );
+ nested.guest.wait_until(
+ "the guest status bar to show the ascended hint",
+ |guest_grid| guest_grid.contains("Descend:"),
+ );
+}
diff --git a/zellij-integration-tests/tests/no_ui_fullscreen.rs b/zellij-integration-tests/tests/no_ui_fullscreen.rs
new file mode 100644
index 00000000..f032c68f
--- /dev/null
+++ b/zellij-integration-tests/tests/no_ui_fullscreen.rs
@@ -0,0 +1,413 @@
+#![cfg(unix)]
+
+use insta::assert_snapshot;
+use zellij_integration_tests::{
+ assert_same_rendered_grid, claim_first_terminal_and_wait_for_prompt, col, keys, normalized,
+ split_right_and_wait_for_prompt, start_zellij, Size, PROMPT, TERMINAL_SIZE,
+};
+use zellij_utils::cli::CliAction;
+use zellij_utils::input::options::PaneFrameStyle;
+
+const FULL_FRAME_CORNER: &str = "┌";
+
+fn toggle_no_ui_fullscreen_via_keybinding(zellij: &zellij_integration_tests::TestSession) {
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('F'));
+}
+
+#[test]
+fn no_ui_fullscreen_hides_ui_and_covers_display() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+
+ let grid_snapshot = zellij.wait_until(
+ "focused pane covers the whole display without chrome",
+ |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ },
+ );
+ assert_snapshot!(normalized(&grid_snapshot));
+ zellij.quit();
+}
+
+#[test]
+fn no_ui_fullscreen_toggle_off_restores_ui_and_layout() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ let before = zellij.wait_until("two panes settled with chrome", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(62).row(2))
+ });
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome hidden while no-ui fullscreen", |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears()
+ });
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ let after = zellij.wait_until("chrome and layout restored", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && !grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.cursor_is_at(col(62).row(2))
+ });
+
+ assert_same_rendered_grid(
+ &after,
+ &before,
+ "layout, UI panes and frames are restored exactly",
+ );
+ zellij.quit();
+}
+
+#[test]
+fn no_ui_fullscreen_works_with_single_pane() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+
+ let grid_snapshot = zellij.wait_until(
+ "single pane covers the whole display without chrome",
+ |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ },
+ );
+ assert_snapshot!(normalized(&grid_snapshot));
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome restored for single pane", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(1))
+ });
+ zellij.quit();
+}
+
+#[test]
+fn resize_terminal_while_no_ui_fullscreen_tracks_display() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ let right_terminal = split_right_and_wait_for_prompt(&zellij);
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome hidden while no-ui fullscreen", |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ });
+
+ zellij.resize(Size {
+ cols: 100,
+ rows: 24,
+ });
+ right_terminal.wait_for_size(
+ "no-ui fullscreen pane tracks the new display size",
+ |cols, rows| cols == 100 && rows == 24,
+ );
+ let grid_snapshot = zellij.wait_until(
+ "no-ui fullscreen still covers the resized display",
+ |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ },
+ );
+ assert_snapshot!(normalized(&grid_snapshot));
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome restored at the new size", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.contains("Ctrl +")
+ && grid_snapshot.cursor_is_at(col(52).row(2))
+ });
+ zellij.quit();
+}
+
+#[test]
+fn no_ui_fullscreen_with_full_frames() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ let right_terminal = split_right_and_wait_for_prompt(&zellij);
+
+ let exit_code = zellij.run_cli_action(CliAction::SetPaneFrameStyle {
+ style: PaneFrameStyle::Full,
+ });
+ assert_eq!(exit_code, 0, "set-pane-frame-style exited cleanly");
+ zellij.wait_until("full frames drawn", |grid_snapshot| {
+ grid_snapshot.contains(FULL_FRAME_CORNER)
+ });
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+
+ right_terminal.wait_for_size(
+ "no-ui fullscreen pane forced frameless and sized to the exact display",
+ |cols, rows| cols == TERMINAL_SIZE.cols as u16 && rows == TERMINAL_SIZE.rows as u16,
+ );
+ let grid_snapshot = zellij.wait_until(
+ "frameless fullscreen pane covers the whole display without chrome",
+ |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && !grid_snapshot.contains(FULL_FRAME_CORNER)
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ },
+ );
+ assert_snapshot!(normalized(&grid_snapshot));
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome and frames restored", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.contains(FULL_FRAME_CORNER)
+ });
+ zellij.quit();
+}
+
+#[test]
+fn no_ui_fullscreen_with_frameless_panes() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ let right_terminal = split_right_and_wait_for_prompt(&zellij);
+
+ let exit_code = zellij.run_cli_action(CliAction::SetPaneFrameStyle {
+ style: PaneFrameStyle::None,
+ });
+ assert_eq!(exit_code, 0, "set-pane-frame-style exited cleanly");
+ zellij.wait_until("titles removed in none mode", |grid_snapshot| {
+ grid_snapshot.status_bar_appears() && !grid_snapshot.contains("Pane #1")
+ });
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+
+ right_terminal.wait_for_size(
+ "frameless no-ui fullscreen pane sized to the exact display",
+ |cols, rows| cols == TERMINAL_SIZE.cols as u16 && rows == TERMINAL_SIZE.rows as u16,
+ );
+ zellij.wait_until(
+ "frameless pane covers the whole display without chrome",
+ |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ },
+ );
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome restored in none mode", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears() && grid_snapshot.status_bar_appears()
+ });
+ zellij.quit();
+}
+
+#[test]
+fn no_ui_fullscreen_on_focused_floating_pane_covers_display() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('w'));
+ let floating_terminal = zellij.expect_pty_spawn();
+ floating_terminal.output(PROMPT);
+ floating_terminal.output(b"floating_marker");
+ zellij.wait_until(
+ "floating pane rendered and focused in normal mode",
+ |grid_snapshot| {
+ grid_snapshot.contains(FULL_FRAME_CORNER)
+ && grid_snapshot.contains("floating_marker")
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.tab_bar_appears()
+ },
+ );
+
+ let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None });
+ assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly");
+
+ zellij.wait_until(
+ "focused floating pane goes no-ui fullscreen and hides the chrome",
+ |grid_snapshot| {
+ grid_snapshot.contains("floating_marker")
+ && !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && !grid_snapshot.contains(FULL_FRAME_CORNER)
+ },
+ );
+
+ let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None });
+ assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly");
+ zellij.wait_until(
+ "toggling off restores the chrome and the floating frame",
+ |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.contains(FULL_FRAME_CORNER)
+ },
+ );
+ zellij.quit();
+}
+
+#[test]
+fn regular_fullscreen_switches_to_no_ui_fullscreen() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('f'));
+ zellij.wait_until("regular fullscreen keeps the chrome", |grid_snapshot| {
+ grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(2))
+ });
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until(
+ "switching kinds hides the chrome without leaving fullscreen",
+ |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ },
+ );
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("fullscreen fully off, layout restored", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && !grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.cursor_is_at(col(62).row(2))
+ });
+ zellij.quit();
+}
+
+#[test]
+fn move_focus_while_no_ui_fullscreen_keeps_no_ui() {
+ let mut zellij = start_zellij();
+ let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("right pane is no-ui fullscreen", |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears()
+ && !grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(2).row(0))
+ });
+
+ zellij.send_stdin(&keys::alt('h'));
+ first_terminal.output(b"left_marker");
+
+ let grid_snapshot = zellij.wait_until(
+ "focus moved to the left pane which is now no-ui fullscreen",
+ |grid_snapshot| grid_snapshot.contains("left_marker"),
+ );
+ assert!(
+ !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears(),
+ "no-ui fullscreen kind survives a focus move"
+ );
+ zellij.quit();
+}
+
+#[test]
+fn new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("chrome hidden while no-ui fullscreen", |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears()
+ });
+
+ zellij.send_stdin(&keys::alt('n'));
+ let new_terminal = zellij.expect_pty_spawn();
+ new_terminal.output(PROMPT);
+
+ let grid_snapshot = zellij.wait_until(
+ "all three panes tiled with chrome after breaking out of no-ui fullscreen",
+ |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && !grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.contains("Pane #1")
+ && grid_snapshot.contains("Pane #2")
+ && grid_snapshot.contains("Pane #3")
+ && grid_snapshot.cursor.is_some()
+ },
+ );
+ assert_snapshot!(normalized(&grid_snapshot));
+ zellij.quit();
+}
+
+#[test]
+fn closing_the_no_ui_fullscreen_pane_restores_the_remaining_layout() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+ let two_pane_state = zellij.wait_until("two panes settled", |grid_snapshot| {
+ grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(2))
+ });
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('d'));
+ let third_terminal = zellij.expect_pty_spawn();
+ third_terminal.output(PROMPT);
+ zellij.wait_until("third pane split below the right pane", |grid_snapshot| {
+ grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(13))
+ });
+
+ toggle_no_ui_fullscreen_via_keybinding(&zellij);
+ zellij.wait_until("third pane is no-ui fullscreen", |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears()
+ });
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('x'));
+ let after_close = zellij.wait_until(
+ "no-ui fullscreen pane closed, two panes and chrome restored",
+ |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && !grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.cursor_is_at(col(62).row(2))
+ },
+ );
+
+ assert_same_rendered_grid(
+ &after_close,
+ &two_pane_state,
+ "closing the no-ui fullscreen pane restores the pre-split layout",
+ );
+ zellij.quit();
+}
+
+#[test]
+fn no_ui_fullscreen_through_the_cli() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None });
+ assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly");
+ zellij.wait_until("cli action hid the chrome", |grid_snapshot| {
+ !grid_snapshot.tab_bar_appears() && !grid_snapshot.status_bar_appears()
+ });
+
+ let exit_code = zellij.run_cli_action(CliAction::ToggleNoUiFullscreen { pane_id: None });
+ assert_eq!(exit_code, 0, "toggle-no-ui-fullscreen exited cleanly");
+ zellij.wait_until("cli action restored the chrome", |grid_snapshot| {
+ grid_snapshot.tab_bar_appears()
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(62).row(2))
+ });
+ zellij.quit();
+}
diff --git a/zellij-integration-tests/tests/panes.rs b/zellij-integration-tests/tests/panes.rs
index 5ed88a1d..2f06612b 100644
--- a/zellij-integration-tests/tests/panes.rs
+++ b/zellij-integration-tests/tests/panes.rs
@@ -2,7 +2,7 @@
use insta::assert_snapshot;
use zellij_integration_tests::{
- claim_first_terminal_and_wait_for_prompt, col, keys, normalized,
+ assert_same_rendered_grid, claim_first_terminal_and_wait_for_prompt, col, keys, normalized,
split_down_and_wait_for_prompt, split_right_and_wait_for_prompt, start_zellij, Coord, Size,
TestRunner, TestSession, PROMPT, TERMINAL_SIZE,
};
@@ -76,6 +76,79 @@ fn toggle_pane_fullscreen() {
}
#[test]
+fn new_pane_while_fullscreen_breaks_out_of_fullscreen() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('f'));
+ zellij.wait_until("focused pane is fullscreen", |grid_snapshot| {
+ grid_snapshot.contains("(FULLSCREEN)")
+ });
+
+ zellij.send_stdin(&keys::alt('n'));
+ let new_terminal = zellij.expect_pty_spawn();
+ new_terminal.output(PROMPT);
+
+ let grid_snapshot = zellij.wait_until(
+ "all three panes tiled after breaking out of fullscreen",
+ |grid_snapshot| {
+ !grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.contains("Pane #1")
+ && grid_snapshot.contains("Pane #2")
+ && grid_snapshot.contains("Pane #3")
+ && grid_snapshot.cursor.is_some()
+ },
+ );
+ assert_snapshot!(normalized(&grid_snapshot));
+ zellij.quit();
+}
+
+#[test]
+fn closing_the_fullscreen_pane_restores_the_remaining_layout() {
+ let mut zellij = start_zellij();
+ claim_first_terminal_and_wait_for_prompt(&zellij);
+ split_right_and_wait_for_prompt(&zellij);
+ let two_pane_state = zellij.wait_until("two panes settled", |grid_snapshot| {
+ grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(2))
+ });
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('d'));
+ let third_terminal = zellij.expect_pty_spawn();
+ third_terminal.output(PROMPT);
+ zellij.wait_until("third pane split below the right pane", |grid_snapshot| {
+ grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(13))
+ });
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('f'));
+ zellij.wait_until("third pane is fullscreen", |grid_snapshot| {
+ grid_snapshot.contains("(FULLSCREEN)")
+ });
+
+ zellij.send_stdin(&keys::ctrl('p'));
+ zellij.send_stdin(&keys::key('x'));
+ let after_close = zellij.wait_until(
+ "fullscreen pane closed, two panes restored",
+ |grid_snapshot| {
+ !grid_snapshot.contains("(FULLSCREEN)")
+ && grid_snapshot.status_bar_appears()
+ && grid_snapshot.cursor_is_at(col(62).row(2))
+ },
+ );
+
+ assert_same_rendered_grid(
+ &after_close,
+ &two_pane_state,
+ "closing the fullscreen pane restores the pre-split layout",
+ );
+ zellij.quit();
+}
+
+#[test]
fn close_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
diff --git a/zellij-integration-tests/tests/search.rs b/zellij-integration-tests/tests/search.rs
index 6c750b0b..e6ac11c0 100644
--- a/zellij-integration-tests/tests/search.rs
+++ b/zellij-integration-tests/tests/search.rs
@@ -109,7 +109,7 @@ fn search_down_to_next_match() {
zellij.send_stdin(&keys::key('p'));
zellij.send_stdin(&keys::key('p'));
zellij.wait_until("upper match revealed by searching up", |grid_snapshot| {
- grid_snapshot.contains("NEEDLE upper")
+ grid_snapshot.contains("NEEDLE upper") && grid_snapshot.contains("SCROLL 15/19")
});
zellij.send_stdin(&keys::key('n'));
@@ -118,6 +118,7 @@ fn search_down_to_next_match() {
zellij.wait_until("lower match revealed by searching down", |grid_snapshot| {
grid_snapshot.contains("NEEDLE lower")
&& !grid_snapshot.contains("NEEDLE upper")
+ && grid_snapshot.contains("SCROLL 6/19")
&& grid_snapshot.contains("PgDn|PgUp")
});
assert_snapshot!(normalized(&grid_snapshot));
diff --git a/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap
new file mode 100644
index 00000000..2da02305
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_breadcrumb_tab_bar_line.snap
@@ -0,0 +1,6 @@
+---
+source: zellij-integration-tests/tests/nested_fullscreen.rs
+assertion_line: 352
+expression: normalized(&fullscreened).lines().next().unwrap()
+---
+ Zellij (test-0 ▸ test-1)  Tab #1  + 
diff --git a/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap
new file mode 100644
index 00000000..002f8384
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth2_guest_fullscreen_fills_display.snap
@@ -0,0 +1,8 @@
+---
+source: zellij-integration-tests/tests/nested_fullscreen.rs
+assertion_line: 162
+expression: normalized(&fullscreen_grid)
+---
+ Zellij (test-0 ▸ test-1)  Tab #1  + 
+█
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap
new file mode 100644
index 00000000..e9985200
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_fullscreen__depth3_inner_fullscreen_breadcrumb.snap
@@ -0,0 +1,9 @@
+---
+source: zellij-integration-tests/tests/nested_fullscreen.rs
+assertion_line: 486
+expression: normalized(&fullscreen_grid)
+---
+ Zellij (test-0 ▸ test-1 ▸ test-2)  Tab #1  + 
+█
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
+
diff --git a/zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap b/zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap
new file mode 100644
index 00000000..18317da3
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_guest_modal__modal_renders_expected_ui.snap
@@ -0,0 +1,16 @@
+---
+source: zellij-integration-tests/tests/nested_guest_modal.rs
+expression: normalized(&steady_modal)
+---
+ Zellij (test)  Tab #1  + 
+ Nested Zellij session detected: test-1
+ What would you like to do?
+ > 1. Zoom in and control this session
+ The session will take up the whole screen. Toggle
+ this on/off with <Ctrl y>
+ 2. Control this session on focus
+ When this pane gains focus, keybindings will be sent
+ to this session. Ascend to the current session with
+ <Ctrl u>
+ <↓↑> select <Enter> confirm <1-2> direct <Esc> dismiss
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap
new file mode 100644
index 00000000..8b290dcd
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_after_first_load_descend.snap
@@ -0,0 +1,26 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&guest_after_new_pane)
+---
+ Zellij (test)  Tab #1  + 
+ Pane #1 │ Pane #2
+$ │█
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap
new file mode 100644
index 00000000..3007dac2
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__guest_reacts_to_passed_through_new_pane_key.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&guest_after_new_pane)
+---
+ Zellij (test)  Tab #1  + 
+ Pane #1 │ Pane #2
+$ │█
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + g  p  t  n  h  s  o  q  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap
new file mode 100644
index 00000000..ed9ccfb8
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_after_guest_cleared_passthrough.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_after_guest_gone)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+───────────────────────────────────────────────────────────┤
+ Pane #3 │
+$ █ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap
new file mode 100644
index 00000000..a71fe392
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_ascend.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_after_new_pane)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Descend: Ctrl + <o> + <[> │
+───────────────────────────────────────────────────────────┤
+ Pane #3 │
+$ █ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap
new file mode 100644
index 00000000..bca169f3
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_acts_on_key_after_mouse_ascend.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_after_new_pane)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ ├────────────────────────────────────────────────────────────
+ │ Pane #3
+ │$ █
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Descend: Ctrl + <o> + <[> │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap
new file mode 100644
index 00000000..782526bf
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_clicking_new_tab_button.snap
@@ -0,0 +1,7 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_after_new_tab)
+---
+ Zellij (test)  Tab #1  Tab #2  + 
+$ █
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap
new file mode 100644
index 00000000..2e1c0853
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_focus_host_binding.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_ascended)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$
+█ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Descend: Ctrl + <o> + <[> │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap
new file mode 100644
index 00000000..0450491b
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_ascended_after_mouse_click.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_after_click)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$ █
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Descend: Ctrl + <o> + <[> │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap
new file mode 100644
index 00000000..5ce2e107
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_descended_on_first_load.snap
@@ -0,0 +1,9 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_descended_on_load)
+---
+ Zellij (test)  test-1  + 
+ Zellij (test)  Tab #1  + 
+█
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
+ Ascend: Ctrl + <o> + <]>
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap
new file mode 100644
index 00000000..1533480f
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_grid_while_descended.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_descended)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$
+█ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + g  p  t  n  h  s  o  q  ...│
+ Ascend: Ctrl + <o> + <]>
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap
new file mode 100644
index 00000000..7df03a1c
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_keys_act_on_host_before_descend.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_focused_on_sibling)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  │$ █
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Descend: Ctrl + <o> + <[> │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap
new file mode 100644
index 00000000..5060b831
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__host_pane_count_unchanged_while_descended.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_pane_count_unchanged)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  Alt <[]>  VERTICAL  │$
+ Pane #1 │ Pane #2 │
+$ │█ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ Ctrl + g  p  t  n  h  s  o  q  ...│
+ Ascend: Ctrl + <o> + <]>
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap
new file mode 100644
index 00000000..b5ee6b38
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_ascend_lands_in_host.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&host_end_state)
+---
+ Zellij (test)  Tab #1  + 
+ test-1 │ Pane #2
+ Zellij (test)  Tab #1  +  Alt <[]>  VERTICAL  │$
+ Pane #1 │ Pane #2 │
+$ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ Descend: Ctrl + <o> + <[> │
+───────────────────────────────────────────────────────────┤
+ Pane #3 │
+$ █ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap
new file mode 100644
index 00000000..28943cec
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__key_after_descend_lands_in_guest.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&guest_end_state)
+---
+ Zellij (test)  Tab #1  + 
+ Pane #1 │ Pane #2
+$ │█
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + g  p  t  n  h  s  o  q  ...
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap
new file mode 100644
index 00000000..cb2da9f0
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_after_inner_ascend.snap
@@ -0,0 +1,11 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&outer_after_inner_ascend)
+---
+ Zellij (test)  test-2 | test-2  + 
+ Zellij (test)  test-2  + 
+ Zellij (test)  Tab #1  + 
+█
+ Descend: Ctrl + <o> + <[>
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
+ Ascend: Ctrl + <o> + <]>
diff --git a/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap
new file mode 100644
index 00000000..d2cc247d
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/nested_sessions__outer_grid_doubly_nested_descended.snap
@@ -0,0 +1,11 @@
+---
+source: zellij-integration-tests/tests/nested_sessions.rs
+expression: normalized(&outer_doubly_nested)
+---
+ Zellij (test)  test-2 | test-2  + 
+ Zellij (test)  test-2  + 
+ Zellij (test)  Tab #1  + 
+█
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
+ Ascend: Ctrl + <o> + <]>
+ Ascend: Ctrl + <o> + <]>
diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap
new file mode 100644
index 00000000..227d5755
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__new_pane_while_no_ui_fullscreen_breaks_out_and_restores_chrome.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/no_ui_fullscreen.rs
+expression: normalized(&grid_snapshot)
+---
+ Zellij (test)  Tab #1  + 
+ Pane #1 │ Pane #2
+$ │$
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ ├────────────────────────────────────────────────────────────
+ │ Pane #3
+ │$ █
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap
new file mode 100644
index 00000000..835b0803
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_hides_ui_and_covers_display.snap
@@ -0,0 +1,6 @@
+---
+source: zellij-integration-tests/tests/no_ui_fullscreen.rs
+expression: normalized(&grid_snapshot)
+---
+$ █
+
diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap
new file mode 100644
index 00000000..835b0803
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_with_full_frames.snap
@@ -0,0 +1,6 @@
+---
+source: zellij-integration-tests/tests/no_ui_fullscreen.rs
+expression: normalized(&grid_snapshot)
+---
+$ █
+
diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap
new file mode 100644
index 00000000..835b0803
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__no_ui_fullscreen_works_with_single_pane.snap
@@ -0,0 +1,6 @@
+---
+source: zellij-integration-tests/tests/no_ui_fullscreen.rs
+expression: normalized(&grid_snapshot)
+---
+$ █
+
diff --git a/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap
new file mode 100644
index 00000000..74c6eafa
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/no_ui_fullscreen__resize_terminal_while_no_ui_fullscreen_tracks_display.snap
@@ -0,0 +1,6 @@
+---
+source: zellij-integration-tests/tests/no_ui_fullscreen.rs
+expression: normalized(&grid_snapshot)
+---
+ █
+
diff --git a/zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap b/zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap
new file mode 100644
index 00000000..7e5ac185
--- /dev/null
+++ b/zellij-integration-tests/tests/snapshots/panes__new_pane_while_fullscreen_breaks_out_of_fullscreen.snap
@@ -0,0 +1,28 @@
+---
+source: zellij-integration-tests/tests/panes.rs
+expression: normalized(&grid_snapshot)
+---
+ Zellij (test)  Tab #1  + 
+ Pane #1 │ Pane #2
+$ │$
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ ├────────────────────────────────────────────────────────────
+ │ Pane #3
+ │$ █
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ │
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap b/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap
index af4123ad..3a4ffdbb 100644
--- a/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap
+++ b/zellij-integration-tests/tests/snapshots/search__search_down_to_next_match.snap
@@ -2,7 +2,7 @@
source: zellij-integration-tests/tests/search.rs
expression: normalized(&grid_snapshot)
---
- Zellij (test)  Tab #1  +  [ SCROLL 15/19 ]
+ Zellij (test)  Tab #1  +  [ SCROLL 6/19 ]
line14
line15
line16
@@ -25,4 +25,4 @@ line32
line33
line34
█ine35 NEEDLE lower
- SEARCHING   ↓↑  Scroll  PgDn|PgUp  Scroll  d|u  Scroll  n  Down  p  Up  c  Case  w  Wrap  o  Whole 
+ SEARCHING   ↓↑  Scroll  PgDn|PgUp  Scroll  d|u  Scroll  n  Down  p  Up  c  Case  w  Wrap  o  Whole 
diff --git a/zellij-integration-tests/tests/startup_host_query.rs b/zellij-integration-tests/tests/startup_host_query.rs
index 514df24f..7c4b6648 100644
--- a/zellij-integration-tests/tests/startup_host_query.rs
+++ b/zellij-integration-tests/tests/startup_host_query.rs
@@ -59,6 +59,22 @@ fn fragmented_attach_reply_burst_does_not_leak_into_focused_pane() {
}
#[test]
+fn theme_query_from_pane_is_answered_when_host_mode_is_unknown() {
+ let mut zellij = start_zellij();
+ let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
+
+ terminal.output(b"\x1b[?996n");
+
+ terminal.wait_for_stdin("theme mode reply reached the pane", |stdin_bytes| {
+ stdin_bytes
+ .windows(9)
+ .any(|window| window == b"\x1b[?997;1n")
+ });
+
+ zellij.quit();
+}
+
+#[test]
fn fragmented_function_key_arrives_intact() {
let mut zellij = start_zellij();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs
index c01df526..42b3a116 100644
--- a/zellij-server/src/background_jobs.rs
+++ b/zellij-server/src/background_jobs.rs
@@ -75,6 +75,8 @@ pub enum BackgroundJob {
FlashTabBell(usize), // usize = tab_id
StopFlashTabBell(usize), // usize = tab_id
MobileGateTimeout(ClientId),
+ StartNestedGuestPing(PaneId),
+ StopNestedGuestPing(PaneId),
Exit,
}
@@ -104,6 +106,8 @@ impl From<&BackgroundJob> for BackgroundJobContext {
BackgroundJob::FlashTabBell(..) => BackgroundJobContext::FlashTabBell,
BackgroundJob::StopFlashTabBell(..) => BackgroundJobContext::StopFlashTabBell,
BackgroundJob::MobileGateTimeout(..) => BackgroundJobContext::MobileGateTimeout,
+ BackgroundJob::StartNestedGuestPing(..) => BackgroundJobContext::StartNestedGuestPing,
+ BackgroundJob::StopNestedGuestPing(..) => BackgroundJobContext::StopNestedGuestPing,
BackgroundJob::Exit => BackgroundJobContext::Exit,
}
}
@@ -161,6 +165,7 @@ pub(crate) fn background_jobs_main(
Arc::new(Mutex::new(HashMap::new()));
let mut flashing_pane_bells: HashMap<PaneId, Arc<AtomicBool>> = HashMap::new();
let mut flashing_tab_bells: HashMap<usize, Arc<AtomicBool>> = HashMap::new();
+ let mut nested_guest_pings: HashMap<PaneId, Arc<AtomicBool>> = HashMap::new();
let http_client = HttpClient::builder()
// TODO: timeout?
@@ -624,6 +629,30 @@ pub(crate) fn background_jobs_main(
.senders
.send_to_screen(ScreenInstruction::SetTabBellFlash(tab_id, false));
},
+ BackgroundJob::StartNestedGuestPing(pane_id) => {
+ if !nested_guest_pings.contains_key(&pane_id) {
+ let is_pinging = Arc::new(AtomicBool::new(true));
+ nested_guest_pings.insert(pane_id, is_pinging.clone());
+ runtime.spawn({
+ let senders = bus.senders.clone();
+ let flag = is_pinging.clone();
+ let ping_interval_ms = crate::nested_guest::ping_interval_ms();
+ async move {
+ while flag.load(Ordering::SeqCst) {
+ let _ = senders.send_to_screen(
+ ScreenInstruction::NestedGuestPingTick { pane_id },
+ );
+ tokio::time::sleep(Duration::from_millis(ping_interval_ms)).await;
+ }
+ }
+ });
+ }
+ },
+ BackgroundJob::StopNestedGuestPing(pane_id) => {
+ if let Some(flag) = nested_guest_pings.remove(&pane_id) {
+ flag.store(false, Ordering::SeqCst);
+ }
+ },
BackgroundJob::MobileGateTimeout(client_id) => {
runtime.spawn({
let senders = bus.senders.clone();
@@ -641,6 +670,9 @@ pub(crate) fn background_jobs_main(
for loading_plugin in loading_plugins.values() {
loading_plugin.store(false, Ordering::SeqCst);
}
+ for nested_guest_ping in nested_guest_pings.values() {
+ nested_guest_ping.store(false, Ordering::SeqCst);
+ }
let cache_file_name =
session_info_cache_file_name(&current_session_name.lock().unwrap().to_owned());
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index cd9dcb5e..daaa938d 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -15,6 +15,7 @@ pub mod background_jobs;
mod global_async_runtime;
mod logging_pipe;
mod mobile_mode;
+pub mod nested_guest;
mod pane_groups;
mod plugins;
mod pty;
@@ -46,6 +47,7 @@ use wasmi::Engine;
use crate::{
os_input_output::ServerOsApi,
+ panes::PaneId,
plugins::{plugin_thread_main, PluginInstruction},
pty::{get_default_shell, pty_thread_main, Pty, PtyInstruction},
screen::{screen_thread_main, ScreenInstruction},
@@ -58,8 +60,8 @@ use zellij_utils::{
DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE, ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE,
},
data::{
- ConnectToSession, InputMode, KeyWithModifier, LayoutInfo, LayoutWithError, Style,
- WebSharing,
+ ConnectToSession, Direction, InputMode, KeyWithModifier, LayoutInfo, LayoutWithError,
+ Style, WebSharing,
},
errors::{prelude::*, ContextType, ErrorInstruction, FatalError, ServerContext},
home::{default_layout_dir, get_default_data_dir},
@@ -138,6 +140,8 @@ pub enum ServerInstruction {
/// loop. The main loop writes `ServerToClientMsg::ForwardQueryToHost`
/// to any connected regular client.
ForwardQueryToHost(u32, Vec<u8>),
+ KeyPassthroughChanged(ClientId, PaneId, PaneId, bool, Option<Direction>, bool),
+ EmitNestedSessionFrameToClient(ClientId, Vec<u8>),
}
impl From<&ServerInstruction> for ServerContext {
@@ -187,6 +191,10 @@ impl From<&ServerInstruction> for ServerContext {
},
ServerInstruction::ClearMouseHelpText(..) => ServerContext::ClearMouseHelpText,
ServerInstruction::ForwardQueryToHost(..) => ServerContext::ForwardQueryToHost,
+ ServerInstruction::KeyPassthroughChanged(..) => ServerContext::KeyPassthroughChanged,
+ ServerInstruction::EmitNestedSessionFrameToClient(..) => {
+ ServerContext::EmitNestedSessionFrameToClient
+ },
}
}
}
@@ -326,6 +334,7 @@ pub(crate) struct SessionMetaData {
pub default_shell: Option<TerminalAction>,
pub current_input_modes: HashMap<ClientId, InputMode>,
pub session_configuration: SessionConfiguration,
+ pub key_passthrough_clients: HashMap<ClientId, PaneId>,
pub web_sharing: WebSharing, // this is a special attribute explicitly set on session
// initialization because we don't want it to be overridden by
// configuration changes, the only way it can be overwritten is by
@@ -356,6 +365,30 @@ impl SessionMetaData {
_ => None,
}
}
+ pub fn remove_key_passthrough_client(&mut self, client_id: ClientId) {
+ self.remove_key_passthrough_client_with_notify(client_id, true);
+ }
+ pub fn remove_key_passthrough_client_with_notify(
+ &mut self,
+ client_id: ClientId,
+ notify_guest: bool,
+ ) {
+ if let Some(pane_id) = self.key_passthrough_clients.remove(&client_id) {
+ let pane_still_active = self.key_passthrough_clients.values().any(|p| *p == pane_id);
+ if !pane_still_active && notify_guest {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ let frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::FocusLost,
+ );
+ let _ = self.senders.send_to_pty_writer(PtyWriteInstruction::Write(
+ frame,
+ terminal_id,
+ None,
+ ));
+ }
+ }
+ }
+ }
pub fn change_mode_for_all_clients(&mut self, input_mode: InputMode) {
let all_clients: Vec<ClientId> = self.current_input_modes.keys().copied().collect();
for client_id in all_clients {
@@ -438,6 +471,10 @@ impl SessionMetaData {
visual_bell: new_config.options.visual_bell.unwrap_or(true),
focus_follows_mouse: new_config.options.focus_follows_mouse.unwrap_or(false),
mouse_click_through: new_config.options.mouse_click_through.unwrap_or(false),
+ nested_session_handling: new_config
+ .options
+ .nested_session_handling
+ .unwrap_or_default(),
})
.unwrap();
self.senders
@@ -1334,6 +1371,9 @@ pub fn start_server_impl(
os_input.remove_client(client_id).unwrap();
} else {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(client_id);
+ }
// Handle regular client removal
remove_client!(client_id, os_input, session_state, session_data);
drop(completion_tx); // prevent deadlock with route thread
@@ -1398,6 +1438,9 @@ pub fn start_server_impl(
os_input.remove_client(client_id).unwrap();
} else {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(client_id);
+ }
// Handle regular client removal
remove_client!(client_id, os_input, session_state, session_data);
session_data
@@ -1450,6 +1493,9 @@ pub fn start_server_impl(
.filter(|c| c != &client_id)
.collect();
for client_id in client_ids {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(client_id);
+ }
let _ = os_input.send_to_client(
client_id,
ServerToClientMsg::Exit {
@@ -1461,6 +1507,9 @@ pub fn start_server_impl(
},
ServerInstruction::DetachSession(client_ids, completion_tx) => {
for client_id in &client_ids {
+ if let Some(session_data) = session_data.write().unwrap().as_mut() {
+ session_data.remove_key_passthrough_client(*client_id);
+ }
let _ = os_input.send_to_client(
*client_id,
ServerToClientMsg::Exit {
@@ -1913,6 +1962,77 @@ pub fn start_server_impl(
}
}
},
+ ServerInstruction::KeyPassthroughChanged(
+ client_id,
+ _old_pane_id,
+ new_pane_id,
+ should_route,
+ entered_from_direction,
+ notify_guest,
+ ) => {
+ let mut session_data = session_data.write().unwrap();
+ if let Some(session_data) = session_data.as_mut() {
+ let previous_pane = session_data
+ .key_passthrough_clients
+ .get(&client_id)
+ .copied();
+
+ if should_route {
+ if previous_pane != Some(new_pane_id) {
+ if let Some(previous_pane_id) = previous_pane {
+ session_data.key_passthrough_clients.remove(&client_id);
+ let pane_still_active = session_data
+ .key_passthrough_clients
+ .values()
+ .any(|p| *p == previous_pane_id);
+ if !pane_still_active && notify_guest {
+ if let crate::panes::PaneId::Terminal(terminal_id) =
+ previous_pane_id
+ {
+ let frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::FocusLost,
+ );
+ let _ = session_data.senders.send_to_pty_writer(
+ PtyWriteInstruction::Write(frame, terminal_id, None),
+ );
+ }
+ }
+ }
+ let pane_already_active = session_data
+ .key_passthrough_clients
+ .values()
+ .any(|p| *p == new_pane_id);
+ session_data
+ .key_passthrough_clients
+ .insert(client_id, new_pane_id);
+ if !pane_already_active {
+ if let crate::panes::PaneId::Terminal(terminal_id) = new_pane_id {
+ let frame = zellij_utils::nested_session::encode_frame(
+ &zellij_utils::nested_session::NestedSessionMessage::FocusGained {
+ from_direction: entered_from_direction,
+ },
+ );
+ let _ = session_data.senders.send_to_pty_writer(
+ PtyWriteInstruction::Write(frame, terminal_id, None),
+ );
+ }
+ }
+ }
+ } else if previous_pane.is_some() {
+ session_data
+ .remove_key_passthrough_client_with_notify(client_id, notify_guest);
+ }
+ }
+ },
+ ServerInstruction::EmitNestedSessionFrameToClient(client_id, payload_bytes) => {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::EmitNestedSessionFrame { payload_bytes },
+ session_state,
+ session_data
+ );
+ },
}
}
@@ -2193,6 +2313,7 @@ fn init_session(
web_sharing: config.options.web_sharing.unwrap_or(WebSharing::Off),
#[cfg(not(feature = "web_server_capability"))]
web_sharing: WebSharing::Disabled,
+ key_passthrough_clients: HashMap::new(),
config_file_path: cli_assets.config_file_path,
}
}
diff --git a/zellij-server/src/nested_guest.rs b/zellij-server/src/nested_guest.rs
new file mode 100644
index 00000000..b63697ad
--- /dev/null
+++ b/zellij-server/src/nested_guest.rs
@@ -0,0 +1,205 @@
+use crate::panes::PaneId;
+use std::collections::{HashMap, HashSet};
+use std::time::{Duration, Instant};
+
+pub const PING_INTERVAL_MS: u64 = 2000;
+pub const PONG_TIMEOUT_MS: u64 = 5000;
+
+pub fn ping_interval_ms() -> u64 {
+ std::env::var("ZELLIJ_NESTED_PING_INTERVAL_MS")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(PING_INTERVAL_MS)
+}
+
+pub fn pong_timeout_ms() -> u64 {
+ std::env::var("ZELLIJ_NESTED_PONG_TIMEOUT_MS")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(PONG_TIMEOUT_MS)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GuestPingTickAction {
+ SendPing,
+ Suspended,
+ Unknown,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AnnounceKind {
+ New,
+ Revived,
+ Refresh,
+}
+
+#[derive(Debug, Default)]
+pub struct NestedGuestTracker {
+ last_heard_from: HashMap<PaneId, Instant>,
+ dormant: HashSet<PaneId>,
+}
+
+impl NestedGuestTracker {
+ pub fn on_announce(&mut self, pane_id: PaneId, now: Instant) -> AnnounceKind {
+ let was_tracked = self.last_heard_from.contains_key(&pane_id);
+ let was_dormant = self.dormant.remove(&pane_id);
+ self.last_heard_from.insert(pane_id, now);
+ if was_tracked {
+ AnnounceKind::Refresh
+ } else if was_dormant {
+ AnnounceKind::Revived
+ } else {
+ AnnounceKind::New
+ }
+ }
+ pub fn on_pong(&mut self, pane_id: PaneId, now: Instant) {
+ if let Some(last_heard_from) = self.last_heard_from.get_mut(&pane_id) {
+ *last_heard_from = now;
+ }
+ }
+ pub fn on_tick(&mut self, pane_id: PaneId, now: Instant) -> GuestPingTickAction {
+ match self.last_heard_from.get(&pane_id) {
+ Some(last_heard_from)
+ if now.duration_since(*last_heard_from)
+ > Duration::from_millis(pong_timeout_ms()) =>
+ {
+ self.last_heard_from.remove(&pane_id);
+ self.dormant.insert(pane_id);
+ GuestPingTickAction::Suspended
+ },
+ Some(_) => GuestPingTickAction::SendPing,
+ None => GuestPingTickAction::Unknown,
+ }
+ }
+ pub fn remove(&mut self, pane_id: PaneId) {
+ self.last_heard_from.remove(&pane_id);
+ self.dormant.remove(&pane_id);
+ }
+ pub fn is_tracked(&self, pane_id: PaneId) -> bool {
+ self.last_heard_from.contains_key(&pane_id)
+ }
+ pub fn is_dormant(&self, pane_id: PaneId) -> bool {
+ self.dormant.contains(&pane_id)
+ }
+ pub fn is_known(&self, pane_id: PaneId) -> bool {
+ self.is_tracked(pane_id) || self.is_dormant(pane_id)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn millis(ms: u64) -> Duration {
+ Duration::from_millis(ms)
+ }
+
+ #[test]
+ fn announced_guest_gets_pinged_and_pong_keeps_it_alive() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let start = Instant::now();
+ tracker.on_announce(pane_id, start);
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(PING_INTERVAL_MS)),
+ GuestPingTickAction::SendPing
+ );
+ tracker.on_pong(pane_id, start + millis(4000));
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(8000)),
+ GuestPingTickAction::SendPing
+ );
+ }
+
+ #[test]
+ fn guest_that_never_pongs_suspends_after_timeout() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let start = Instant::now();
+ tracker.on_announce(pane_id, start);
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 1)),
+ GuestPingTickAction::Suspended
+ );
+ assert!(!tracker.is_tracked(pane_id));
+ assert!(tracker.is_dormant(pane_id));
+ assert!(tracker.is_known(pane_id));
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 2)),
+ GuestPingTickAction::Unknown
+ );
+ }
+
+ #[test]
+ fn suspended_guest_revives_on_reannounce_however_long_later() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let start = Instant::now();
+ tracker.on_announce(pane_id, start);
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 1)),
+ GuestPingTickAction::Suspended
+ );
+ let much_later = start + millis(PONG_TIMEOUT_MS + 120_000);
+ assert_eq!(
+ tracker.on_announce(pane_id, much_later),
+ AnnounceKind::Revived
+ );
+ assert!(tracker.is_tracked(pane_id));
+ assert!(!tracker.is_dormant(pane_id));
+ assert_eq!(
+ tracker.on_tick(pane_id, much_later + millis(PING_INTERVAL_MS)),
+ GuestPingTickAction::SendPing
+ );
+ }
+
+ #[test]
+ fn reannounce_of_live_guest_is_a_refresh() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let start = Instant::now();
+ assert_eq!(tracker.on_announce(pane_id, start), AnnounceKind::New);
+ assert_eq!(
+ tracker.on_announce(pane_id, start + millis(1000)),
+ AnnounceKind::Refresh
+ );
+ assert!(tracker.is_tracked(pane_id));
+ }
+
+ #[test]
+ fn pong_refreshes_the_expiry_deadline() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let start = Instant::now();
+ tracker.on_announce(pane_id, start);
+ tracker.on_pong(pane_id, start + millis(4000));
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(PONG_TIMEOUT_MS + 1000)),
+ GuestPingTickAction::SendPing
+ );
+ assert_eq!(
+ tracker.on_tick(pane_id, start + millis(4000 + PONG_TIMEOUT_MS + 1)),
+ GuestPingTickAction::Suspended
+ );
+ }
+
+ #[test]
+ fn pong_from_unannounced_pane_is_ignored() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let now = Instant::now();
+ tracker.on_pong(pane_id, now);
+ assert!(!tracker.is_tracked(pane_id));
+ assert_eq!(tracker.on_tick(pane_id, now), GuestPingTickAction::Unknown);
+ }
+
+ #[test]
+ fn removed_guest_stops_being_tracked() {
+ let mut tracker = NestedGuestTracker::default();
+ let pane_id = PaneId::Terminal(1);
+ let now = Instant::now();
+ tracker.on_announce(pane_id, now);
+ tracker.remove(pane_id);
+ assert_eq!(tracker.on_tick(pane_id, now), GuestPingTickAction::Unknown);
+ }
+}
diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs
index 0ff972cf..896e22a2 100644
--- a/zellij-server/src/panes/floating_panes/mod.rs
+++ b/zellij-server/src/panes/floating_panes/mod.rs
@@ -27,6 +27,7 @@ use zellij_utils::{
errors::prelude::*,
input::command::RunCommand,
input::layout::{FloatingPaneLayout, Run, RunPluginOrAlias},
+ input::options::PaneFrameStyle,
pane_size::{Dimension, Offset, PaneGeom, Size, SizeInPixels, Viewport},
};
@@ -53,6 +54,11 @@ pub struct FloatingPanes {
// last_position)
senders: ThreadSenders,
window_title: Option<String>,
+ dimmed_clients: HashSet<ClientId>,
+ fullscreen_covers_ui: Rc<RefCell<bool>>,
+ fullscreen_pane_id: Option<PaneId>,
+ floating_fullscreen_covers_ui: bool,
+ pane_frame_style: PaneFrameStyle,
}
#[allow(clippy::borrowed_box)]
@@ -65,6 +71,8 @@ impl FloatingPanes {
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>,
character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
+ fullscreen_covers_ui: Rc<RefCell<bool>>,
+ pane_frame_style: PaneFrameStyle,
session_is_mirrored: bool,
default_mode_info: ModeInfo,
style: Style,
@@ -89,9 +97,39 @@ impl FloatingPanes {
pane_being_moved_with_mouse: None,
senders,
window_title: None,
+ dimmed_clients: HashSet::new(),
+ fullscreen_covers_ui,
+ fullscreen_pane_id: None,
+ floating_fullscreen_covers_ui: false,
+ pane_frame_style,
}
}
+ pub fn set_pane_frame_style(&mut self, pane_frame_style: PaneFrameStyle) {
+ self.pane_frame_style = pane_frame_style;
+ if self.fullscreen_pane_id.is_some() && !self.floating_fullscreen_covers_ui {
+ let _ = self.set_pane_frames();
+ }
+ }
+ pub fn set_client_dimmed(&mut self, client_id: ClientId, dimmed: bool) {
+ if dimmed {
+ self.dimmed_clients.insert(client_id);
+ } else {
+ self.dimmed_clients.remove(&client_id);
+ }
+ self.set_force_render();
+ }
pub fn stack(&self) -> Option<FloatingPanesStack> {
+ if *self.fullscreen_covers_ui.borrow() {
+ return None;
+ }
+ if let Some(fullscreen_pane_id) = self.fullscreen_pane_id {
+ return self
+ .panes
+ .get(&fullscreen_pane_id)
+ .map(|p| FloatingPanesStack {
+ layers: vec![p.current_geom()],
+ });
+ }
if self.panes_are_visible() {
let layers: Vec<PaneGeom> = self
.z_indices
@@ -221,7 +259,7 @@ impl FloatingPanes {
.and_then(|active_pane_id| self.panes.get_mut(active_pane_id))
}
pub fn panes_are_visible(&self) -> bool {
- self.show_panes
+ self.show_panes && !*self.fullscreen_covers_ui.borrow()
}
pub fn has_active_panes(&self) -> bool {
!self.active_panes.is_empty()
@@ -378,7 +416,43 @@ impl FloatingPanes {
let err_context =
|pane_id: &PaneId| format!("failed to activate frame on pane {pane_id:?}");
+ let no_ui_fullscreen_pane_id = if self.floating_fullscreen_covers_ui {
+ self.fullscreen_pane_id
+ } else {
+ None
+ };
+ let regular_fullscreen_pane_id = if self.floating_fullscreen_covers_ui {
+ None
+ } else {
+ self.fullscreen_pane_id
+ };
+ let pane_frame_style = self.pane_frame_style;
for pane in self.panes.values_mut() {
+ if no_ui_fullscreen_pane_id == Some(pane.pid()) {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ resize_pty!(pane, os_api, self.senders, self.character_cell_size)
+ .with_context(|| err_context(&pane.pid()))?;
+ continue;
+ }
+ if regular_fullscreen_pane_id == Some(pane.pid()) {
+ if pane.borderless() {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ } else if pane_frame_style.draws_full_frames() {
+ pane.set_frame(true);
+ pane.set_content_offset(Offset::frame(1));
+ } else if pane_frame_style.draws_titles() {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::shift_right_top_and_bottom(0, 1, 0));
+ } else {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ }
+ resize_pty!(pane, os_api, self.senders, self.character_cell_size)
+ .with_context(|| err_context(&pane.pid()))?;
+ continue;
+ }
// floating panes should always have a frame unless explicitly set otherwise
if !pane.borderless() {
pane.set_frame(true);
@@ -417,6 +491,11 @@ impl FloatingPanes {
};
for (kind, pane) in &self.panes {
+ if let Some(fullscreen_pane_id) = self.fullscreen_pane_id {
+ if kind != &fullscreen_pane_id {
+ continue;
+ }
+ }
match kind {
PaneId::Terminal(_) => {
output.add_pane_contents(
@@ -451,7 +530,17 @@ impl FloatingPanes {
}
}
- let mut floating_panes: Vec<_> = if self.panes_are_visible() {
+ let fullscreen_pane_id = self.fullscreen_pane_id;
+ let no_ui_fullscreen = self.floating_fullscreen_covers_ui && fullscreen_pane_id.is_some();
+ let regular_fullscreen =
+ fullscreen_pane_id.is_some() && !self.floating_fullscreen_covers_ui;
+ let pane_frame_style = self.pane_frame_style;
+ let mut floating_panes: Vec<_> = if let Some(fullscreen_pane_id) = fullscreen_pane_id {
+ self.panes
+ .iter_mut()
+ .filter(|(id, _)| **id == fullscreen_pane_id)
+ .collect()
+ } else if self.panes_are_visible() {
self.panes.iter_mut().collect()
} else if self.has_pinned_panes() {
self.panes
@@ -479,6 +568,14 @@ impl FloatingPanes {
let show_help_text = active_panes.iter().any(|(client_id, pane_id)| {
pane_id == &pane.pid() && help_text_visible.get(client_id).copied().unwrap_or(false)
});
+ let pane_has_guest_modal = pane.has_guest_modal_for_any_client();
+ let pane_is_regular_fullscreen =
+ regular_fullscreen && Some(**kind) == fullscreen_pane_id;
+ let should_draw_pane_frames = if pane_is_regular_fullscreen {
+ pane_frame_style.draws_full_frames()
+ } else {
+ true
+ };
let mut pane_contents_and_ui = PaneContentsAndUi::new(
pane,
output,
@@ -488,12 +585,13 @@ impl FloatingPanes {
Some(z_index + 1), // +1 because 0 is reserved for non-floating panes
false,
false,
- true,
+ should_draw_pane_frames,
mouse_hover_pane_id,
current_pane_group.clone(),
show_help_text,
false,
mouse_scroll_resize,
+ self.dimmed_clients.clone(),
);
for client_id in &connected_clients {
let client_mode = self
@@ -503,15 +601,21 @@ impl FloatingPanes {
.unwrap_or(&self.default_mode_info)
.mode;
let is_floating = true;
- pane_contents_and_ui
- .render_pane_frame(
- *client_id,
- client_mode,
- self.session_is_mirrored,
- is_floating,
- pane_is_selectable,
- )
- .with_context(err_context)?;
+ let skip_frame = no_ui_fullscreen
+ || (pane_is_regular_fullscreen
+ && !pane_frame_style.draws_full_frames()
+ && !pane_frame_style.draws_titles());
+ if !skip_frame {
+ pane_contents_and_ui
+ .render_pane_frame(
+ *client_id,
+ client_mode,
+ self.session_is_mirrored,
+ is_floating,
+ pane_is_selectable,
+ )
+ .with_context(err_context)?;
+ }
if let PaneId::Plugin(..) = kind {
pane_contents_and_ui
.render_pane_contents_for_client(*client_id)
@@ -529,9 +633,28 @@ impl FloatingPanes {
.with_context(err_context)?;
}
if let PaneId::Terminal(..) = kind {
- pane_contents_and_ui
- .render_pane_contents_to_multiple_clients(connected_clients.iter().copied())
- .with_context(err_context)?;
+ if pane_has_guest_modal {
+ let (modal_clients, plain_clients): (Vec<ClientId>, Vec<ClientId>) =
+ connected_clients.iter().copied().partition(|client_id| {
+ pane_contents_and_ui.client_has_guest_modal(*client_id)
+ });
+ if !plain_clients.is_empty() {
+ pane_contents_and_ui
+ .render_pane_contents_to_multiple_clients(plain_clients.iter().copied())
+ .with_context(err_context)?;
+ } else {
+ pane_contents_and_ui.drain_pane_render_state();
+ }
+ for client_id in modal_clients {
+ pane_contents_and_ui
+ .render_guest_modal_for_client(client_id)
+ .with_context(err_context)?;
+ }
+ } else {
+ pane_contents_and_ui
+ .render_pane_contents_to_multiple_clients(connected_clients.iter().copied())
+ .with_context(err_context)?;
+ }
}
}
Ok(())
@@ -638,6 +761,11 @@ impl FloatingPanes {
}
}
+ fn reenter_fullscreen_on_active_pane(&mut self, client_id: ClientId, covers_ui: bool) {
+ if let Some(active_pane_id) = self.get_active_pane_id(client_id) {
+ self.set_fullscreen(active_pane_id, covers_ui);
+ }
+ }
pub fn move_focus(
&mut self,
client_id: ClientId,
@@ -648,6 +776,13 @@ impl FloatingPanes {
let _err_context = || {
format!("failed to move focus of floating pane {direction:?} for client {client_id}")
};
+ if self.fullscreen_pane_id.is_some() {
+ let covers_ui = self.floating_fullscreen_covers_ui;
+ self.unset_fullscreen();
+ let ret = self.move_focus(client_id, connected_clients, direction);
+ self.reenter_fullscreen_on_active_pane(client_id, covers_ui);
+ return ret;
+ }
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
@@ -728,6 +863,13 @@ impl FloatingPanes {
Ok(false)
}
pub fn focus_pane_on_edge(&mut self, direction: Direction, client_id: ClientId) {
+ if self.fullscreen_pane_id.is_some() {
+ let covers_ui = self.floating_fullscreen_covers_ui;
+ self.unset_fullscreen();
+ self.focus_pane_on_edge(direction, client_id);
+ self.reenter_fullscreen_on_active_pane(client_id, covers_ui);
+ return;
+ }
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
let floating_pane_grid = FloatingPaneGrid::new(
@@ -741,8 +883,51 @@ impl FloatingPanes {
self.set_force_render();
}
}
+ pub fn focus_pane_adjacent_to(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ client_id: ClientId,
+ ) -> Option<PaneId> {
+ if !self.panes.contains_key(&pane_id) {
+ return None;
+ }
+ let display_area = *self.display_area.borrow();
+ let viewport = *self.viewport.borrow();
+ let floating_pane_grid = FloatingPaneGrid::new(
+ &mut self.panes,
+ &mut self.desired_pane_positions,
+ display_area,
+ viewport,
+ );
+ let next_index = match direction {
+ Direction::Left => floating_pane_grid.next_selectable_pane_id_to_the_left(&pane_id),
+ Direction::Down => floating_pane_grid.next_selectable_pane_id_below(&pane_id),
+ Direction::Up => floating_pane_grid.next_selectable_pane_id_above(&pane_id),
+ Direction::Right => floating_pane_grid.next_selectable_pane_id_to_the_right(&pane_id),
+ }?;
+ if let Some(previously_active_pane) = self.get_active_pane_mut(client_id) {
+ previously_active_pane.set_should_render(true);
+ previously_active_pane.render_full_viewport();
+ }
+ if let Some(next_active_pane) = self.get_pane_mut(next_index) {
+ next_active_pane.set_should_render(true);
+ next_active_pane.render_full_viewport();
+ }
+ self.focus_pane(next_index, client_id);
+ self.set_pane_active_at(next_index);
+ self.set_force_render();
+ Some(next_index)
+ }
pub fn focus_last_pane(&mut self, client_id: ClientId) {
+ if self.fullscreen_pane_id.is_some() {
+ let covers_ui = self.floating_fullscreen_covers_ui;
+ self.unset_fullscreen();
+ self.focus_last_pane(client_id);
+ self.reenter_fullscreen_on_active_pane(client_id, covers_ui);
+ return;
+ }
let currently_active_pane_id = self.active_panes.get(&client_id).copied();
let last_pane_id = self
.active_panes
@@ -777,6 +962,9 @@ impl FloatingPanes {
}
}
pub fn move_pane_down(&mut self, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ return;
+ }
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
let mut floating_pane_grid = FloatingPaneGrid::new(
@@ -794,6 +982,9 @@ impl FloatingPanes {
}
}
pub fn move_pane_up(&mut self, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ return;
+ }
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
let mut floating_pane_grid = FloatingPaneGrid::new(
@@ -811,6 +1002,9 @@ impl FloatingPanes {
}
}
pub fn move_pane_left(&mut self, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ return;
+ }
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
let mut floating_pane_grid = FloatingPaneGrid::new(
@@ -828,6 +1022,9 @@ impl FloatingPanes {
}
}
pub fn move_pane_right(&mut self, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ return;
+ }
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
let mut floating_pane_grid = FloatingPaneGrid::new(
@@ -850,6 +1047,9 @@ impl FloatingPanes {
}
}
pub fn move_pane(&mut self, search_backwards: bool, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ return;
+ }
let new_position_id = {
let pane_grid = FloatingPaneGrid::new(
&mut self.panes,
@@ -997,6 +1197,132 @@ impl FloatingPanes {
self.set_pane_active_at(pane_id);
self.set_force_render();
}
+ pub fn fullscreen_is_active(&self) -> bool {
+ self.fullscreen_pane_id.is_some()
+ }
+ pub fn fullscreen_pane_id(&self) -> Option<PaneId> {
+ self.fullscreen_pane_id
+ }
+ pub fn fullscreen_covers_ui(&self) -> bool {
+ self.fullscreen_pane_id.is_some() && self.floating_fullscreen_covers_ui
+ }
+ fn expand_pane_over_area(
+ &mut self,
+ pane_id: PaneId,
+ x: usize,
+ y: usize,
+ rows: usize,
+ cols: usize,
+ ) {
+ if let Some(pane) = self.panes.get_mut(&pane_id) {
+ let mut expanded_geom = PaneGeom {
+ x,
+ y,
+ ..Default::default()
+ };
+ expanded_geom.rows.set_inner(rows);
+ expanded_geom.cols.set_inner(cols);
+ pane.set_geom_override(expanded_geom);
+ }
+ }
+ fn set_fullscreen(&mut self, pane_id: PaneId, covers_ui: bool) {
+ if !self.panes.contains_key(&pane_id) {
+ log::error!("No floating pane with id: {:?} found", pane_id);
+ return;
+ }
+ if covers_ui {
+ let display_area = { *self.display_area.borrow() };
+ self.expand_pane_over_area(pane_id, 0, 0, display_area.rows, display_area.cols);
+ } else {
+ let viewport = { *self.viewport.borrow() };
+ self.expand_pane_over_area(
+ pane_id,
+ viewport.x,
+ viewport.y,
+ viewport.rows,
+ viewport.cols,
+ );
+ }
+ let pane_frame_style = self.pane_frame_style;
+ if let Some(pane) = self.panes.get_mut(&pane_id) {
+ if covers_ui {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ } else if pane.borderless() {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ } else if pane_frame_style.draws_full_frames() {
+ pane.set_frame(true);
+ pane.set_content_offset(Offset::frame(1));
+ } else if pane_frame_style.draws_titles() {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::shift_right_top_and_bottom(0, 1, 0));
+ } else {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ }
+ }
+ let _ = resize_pty!(
+ self.panes.get_mut(&pane_id).unwrap(),
+ os_api,
+ self.senders,
+ self.character_cell_size
+ );
+ self.fullscreen_pane_id = Some(pane_id);
+ self.floating_fullscreen_covers_ui = covers_ui;
+ self.focus_pane_for_all_clients(pane_id);
+ self.set_force_render();
+ }
+ pub fn unset_fullscreen(&mut self) {
+ if let Some(fullscreen_pane_id) = self.fullscreen_pane_id.take() {
+ if let Some(pane) = self.panes.get_mut(&fullscreen_pane_id) {
+ pane.reset_size_and_position_override();
+ if !pane.borderless() {
+ pane.set_frame(true);
+ pane.set_content_offset(Offset::frame(1));
+ } else {
+ pane.set_content_offset(Offset::default());
+ }
+ let _ = resize_pty!(pane, os_api, self.senders, self.character_cell_size);
+ }
+ self.floating_fullscreen_covers_ui = false;
+ self.set_force_render();
+ }
+ }
+ pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ self.unset_fullscreen();
+ } else {
+ self.set_fullscreen(pane_id, false);
+ }
+ }
+ pub fn toggle_pane_no_ui_fullscreen(&mut self, pane_id: PaneId) {
+ if self.fullscreen_pane_id.is_some() {
+ let was_no_ui = self.floating_fullscreen_covers_ui;
+ self.unset_fullscreen();
+ if !was_no_ui {
+ self.set_fullscreen(pane_id, true);
+ }
+ } else {
+ self.set_fullscreen(pane_id, true);
+ }
+ }
+ pub fn toggle_active_pane_fullscreen(&mut self, client_id: ClientId) {
+ if let Some(active_pane_id) = self
+ .fullscreen_pane_id
+ .or_else(|| self.get_active_pane_id(client_id))
+ {
+ self.toggle_pane_fullscreen(active_pane_id);
+ }
+ }
+ pub fn toggle_active_pane_no_ui_fullscreen(&mut self, client_id: ClientId) {
+ if let Some(active_pane_id) = self
+ .fullscreen_pane_id
+ .or_else(|| self.get_active_pane_id(client_id))
+ {
+ self.toggle_pane_no_ui_fullscreen(active_pane_id);
+ }
+ }
pub fn focus_pane(&mut self, pane_id: PaneId, client_id: ClientId) {
if let Some(focused_pane) = self.active_panes.get(&client_id) {
if pane_id != *focused_pane {
@@ -1191,6 +1517,9 @@ impl FloatingPanes {
}
pub fn move_pane_with_mouse(&mut self, position: Position, search_selectable: bool) -> bool {
// true => handled, false => not handled (eg. no pane at this position)
+ if *self.fullscreen_covers_ui.borrow() || self.fullscreen_pane_id.is_some() {
+ return false;
+ }
let show_panes = self.show_panes;
if self.pane_being_moved_with_mouse.is_some() {
if self.move_pane_to_position(&position) {
@@ -1338,7 +1667,7 @@ impl FloatingPanes {
pane_info_for_pane.is_floating = true;
pane_info_for_pane.is_suppressed = false;
pane_info_for_pane.is_focused = is_focused;
- pane_info_for_pane.is_fullscreen = false;
+ pane_info_for_pane.is_fullscreen = self.fullscreen_pane_id == Some(*pane_id);
pane_infos.push(pane_info_for_pane);
}
pane_infos
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index ab87a2fe..7ccd7599 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -22,6 +22,7 @@ use zellij_utils::{
consts::{DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE},
data::{Palette, PaletteColor, Styling},
input::mouse::{MouseEvent, MouseEventType},
+ nested_session::{self, NestedSessionMessage},
pane_size::SizeInPixels,
position::Position,
};
@@ -652,7 +653,9 @@ pub struct Grid {
/// Zellij should forward to the host terminal; the host's reply is
/// later routed back to this pane's pty.
pub pending_forwarded_queries: Vec<crate::host_query::HostQuery>,
+ pub pending_nested_session_messages: Vec<NestedSessionMessage>,
ui_component_bytes: Option<Vec<u8>>,
+ nested_frame_bytes: Option<Vec<u8>>,
style: Style,
debug: bool,
arrow_fonts: bool,
@@ -971,7 +974,9 @@ impl Grid {
pending_osc7_cwd: None,
pending_desktop_notifications: Vec::new(),
pending_forwarded_queries: Vec::new(),
+ pending_nested_session_messages: Vec::new(),
ui_component_bytes: None,
+ nested_frame_bytes: None,
style,
debug,
arrow_fonts,
@@ -3508,6 +3513,12 @@ impl Perform for Grid {
} else if c == 'z' {
// UI-component (Zellij internal)
self.ui_component_bytes = Some(vec![]);
+ } else if c == 'n'
+ && intermediates.is_empty()
+ && params.len() == 1
+ && params.iter().next() == Some(&[nested_session::NESTED_DCS_PARAM][..])
+ {
+ self.nested_frame_bytes = Some(vec![]);
}
}
@@ -3519,6 +3530,8 @@ impl Perform for Grid {
self.should_render = false;
} else if let Some(ui_component_bytes) = self.ui_component_bytes.as_mut() {
ui_component_bytes.push(byte);
+ } else if let Some(nested_frame_bytes) = self.nested_frame_bytes.as_mut() {
+ nested_frame_bytes.push(byte);
}
}
@@ -3532,6 +3545,12 @@ impl Perform for Grid {
UiComponentParser::new(self, style, arrow_fonts)
.parse(component_bytes.collect())
.non_fatal();
+ } else if let Some(nested_frame_bytes) = self.nested_frame_bytes.take() {
+ if let Some(message) = nested_session::decode_base64(&nested_frame_bytes)
+ .and_then(|payload_bytes| nested_session::decode_payload(&payload_bytes))
+ {
+ self.pending_nested_session_messages.push(message);
+ }
}
self.mark_for_rerender();
}
diff --git a/zellij-server/src/panes/mod.rs b/zellij-server/src/panes/mod.rs
index 86dab304..21680415 100644
--- a/zellij-server/src/panes/mod.rs
+++ b/zellij-server/src/panes/mod.rs
@@ -2,6 +2,7 @@ pub mod alacritty_functions;
pub mod grid;
pub mod hyperlink_tracker;
pub mod link_handler;
+pub mod nested_session_modal;
pub mod selection;
pub mod sixel;
pub mod terminal_character;
diff --git a/zellij-server/src/panes/nested_session_modal.rs b/zellij-server/src/panes/nested_session_modal.rs
new file mode 100644
index 00000000..7fcad47b
--- /dev/null
+++ b/zellij-server/src/panes/nested_session_modal.rs
@@ -0,0 +1,531 @@
+use unicode_width::UnicodeWidthChar;
+use unicode_width::UnicodeWidthStr;
+use zellij_utils::data::Style;
+
+use crate::output::CharacterChunk;
+use crate::panes::terminal_character::{
+ AnsiCode, RcCharacterStyles, TerminalCharacter, RESET_STYLES,
+};
+
+#[derive(Debug, Clone, Default)]
+pub struct GuestModalShortcuts {
+ pub zoom: Vec<String>,
+ pub ascend: Vec<String>,
+ pub descend: Vec<String>,
+}
+
+type GuestModalSegment = (String, RcCharacterStyles);
+
+fn guest_modal_row_width(segments: &[GuestModalSegment]) -> usize {
+ segments.iter().map(|(text, _)| text.width()).sum()
+}
+
+fn guest_modal_empty_row(
+ columns: usize,
+ content_x: usize,
+ absolute_y: usize,
+ fill_styles: RcCharacterStyles,
+) -> CharacterChunk {
+ guest_modal_segment_row(
+ &[],
+ 0,
+ 0,
+ columns,
+ content_x,
+ absolute_y,
+ fill_styles.clone(),
+ fill_styles,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn guest_modal_segment_row(
+ segments: &[GuestModalSegment],
+ left_pad: usize,
+ block_width: usize,
+ columns: usize,
+ content_x: usize,
+ absolute_y: usize,
+ outer_fill: RcCharacterStyles,
+ block_fill: RcCharacterStyles,
+) -> CharacterChunk {
+ let mut characters: Vec<TerminalCharacter> = Vec::with_capacity(columns);
+ let block_end = (left_pad + block_width).min(columns);
+ let mut used = 0;
+ while used < left_pad && used < columns {
+ characters.push(TerminalCharacter::new_singlewidth_styled(
+ ' ',
+ outer_fill.clone(),
+ ));
+ used += 1;
+ }
+ for (text, styles) in segments {
+ for character in text.chars() {
+ let width = character.width().unwrap_or(0).max(1);
+ if used + width > columns {
+ break;
+ }
+ characters.push(TerminalCharacter::new_styled(character, styles.clone()));
+ used += width;
+ }
+ }
+ while used < block_end {
+ characters.push(TerminalCharacter::new_singlewidth_styled(
+ ' ',
+ block_fill.clone(),
+ ));
+ used += 1;
+ }
+ while used < columns {
+ characters.push(TerminalCharacter::new_singlewidth_styled(
+ ' ',
+ outer_fill.clone(),
+ ));
+ used += 1;
+ }
+ CharacterChunk::new(characters, content_x, absolute_y)
+}
+
+fn guest_modal_hint_segments(
+ hint: &str,
+ fill_styles: RcCharacterStyles,
+ keycode_styles: RcCharacterStyles,
+) -> Vec<GuestModalSegment> {
+ let mut segments: Vec<GuestModalSegment> = Vec::new();
+ let mut buffer = String::new();
+ let mut in_keycode = false;
+ for character in hint.chars() {
+ match character {
+ '<' => {
+ if !buffer.is_empty() {
+ segments.push((std::mem::take(&mut buffer), fill_styles.clone()));
+ }
+ buffer.push('<');
+ in_keycode = true;
+ },
+ '>' => {
+ buffer.push('>');
+ segments.push((std::mem::take(&mut buffer), keycode_styles.clone()));
+ in_keycode = false;
+ },
+ _ => buffer.push(character),
+ }
+ }
+ if !buffer.is_empty() {
+ let styles = if in_keycode {
+ keycode_styles
+ } else {
+ fill_styles
+ };
+ segments.push((buffer, styles));
+ }
+ segments
+}
+
+struct GuestModalStyles {
+ fill: RcCharacterStyles,
+ title: RcCharacterStyles,
+ session: RcCharacterStyles,
+ keycode: RcCharacterStyles,
+ prompt: RcCharacterStyles,
+ option_marker: RcCharacterStyles,
+ option_title: RcCharacterStyles,
+ selected_marker: RcCharacterStyles,
+ selected_title: RcCharacterStyles,
+ selected_fill: RcCharacterStyles,
+ selected_keycode: RcCharacterStyles,
+}
+
+fn guest_modal_keycode_words(
+ keys: &[String],
+ fill_styles: RcCharacterStyles,
+ keycode_styles: RcCharacterStyles,
+) -> Vec<GuestModalSegment> {
+ if keys.is_empty() {
+ return vec![("<unbound>".to_string(), fill_styles)];
+ }
+ let mut words: Vec<GuestModalSegment> = Vec::new();
+ for (index, key) in keys.iter().enumerate() {
+ if index > 0 {
+ words.push(("+".to_string(), fill_styles.clone()));
+ }
+ words.push((format!("<{}>", key), keycode_styles.clone()));
+ }
+ words
+}
+
+fn guest_modal_text_words(text: &str, styles: RcCharacterStyles) -> Vec<GuestModalSegment> {
+ text.split_whitespace()
+ .map(|word| (word.to_string(), styles.clone()))
+ .collect()
+}
+
+fn guest_modal_wrap_words(
+ words: &[GuestModalSegment],
+ indent: usize,
+ indent_styles: RcCharacterStyles,
+ width: usize,
+) -> Vec<Vec<GuestModalSegment>> {
+ let content_width = width.saturating_sub(indent).max(1);
+ let mut lines: Vec<Vec<GuestModalSegment>> = Vec::new();
+ let mut current: Vec<GuestModalSegment> = Vec::new();
+ let mut current_width = 0;
+ for (word, styles) in words {
+ let word_width = word.width();
+ let extra = if current.is_empty() {
+ word_width
+ } else {
+ 1 + word_width
+ };
+ if !current.is_empty() && current_width + extra > content_width {
+ lines.push(std::mem::take(&mut current));
+ current_width = 0;
+ }
+ if current.is_empty() {
+ current.push((word.clone(), styles.clone()));
+ current_width = word_width;
+ } else {
+ current.push((" ".to_string(), styles.clone()));
+ current.push((word.clone(), styles.clone()));
+ current_width += 1 + word_width;
+ }
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ if lines.is_empty() {
+ lines.push(Vec::new());
+ }
+ for line in lines.iter_mut() {
+ line.insert(0, (" ".repeat(indent), indent_styles.clone()));
+ }
+ lines
+}
+
+pub fn guest_modal_chunks(
+ columns: usize,
+ rows: usize,
+ content_x: usize,
+ content_y: usize,
+ style: &Style,
+ session_name: &str,
+ selected: usize,
+ shortcuts: &GuestModalShortcuts,
+) -> Vec<CharacterChunk> {
+ if rows < 1 || columns < 1 {
+ return vec![];
+ }
+ let styles = guest_modal_styles(style);
+ let fill_styles = styles.fill.clone();
+
+ let mut chunks: Vec<CharacterChunk> = Vec::with_capacity(rows);
+ for row in 0..rows {
+ chunks.push(guest_modal_empty_row(
+ columns,
+ content_x,
+ content_y + row,
+ fill_styles.clone(),
+ ));
+ }
+
+ let layout = guest_modal_layout(columns, rows, &styles, session_name, selected, shortcuts);
+ for (offset, row) in layout.rows.iter().enumerate() {
+ let current_row = layout.start_row + offset;
+ if current_row >= rows {
+ break;
+ }
+ chunks[current_row] = guest_modal_segment_row(
+ &row.segments,
+ layout.left_pad,
+ layout.block_width,
+ columns,
+ content_x,
+ content_y + current_row,
+ fill_styles.clone(),
+ row.block_fill.clone(),
+ );
+ }
+
+ chunks
+}
+
+fn guest_modal_styles(style: &Style) -> GuestModalStyles {
+ let base_declaration = style.colors.text_unselected;
+ let selected_declaration = style.colors.text_selected;
+ let fill: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.base)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .into();
+ let title: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.emphasis_0)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let session: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.emphasis_2)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let keycode: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.emphasis_3)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let prompt: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.base)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let option_marker: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.base)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let option_title: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(base_declaration.emphasis_1)))
+ .background(Some(AnsiCode::from(base_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let selected_marker: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(selected_declaration.base)))
+ .background(Some(AnsiCode::from(selected_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let selected_title: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(selected_declaration.emphasis_1)))
+ .background(Some(AnsiCode::from(selected_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ let selected_fill: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(selected_declaration.base)))
+ .background(Some(AnsiCode::from(selected_declaration.background)))
+ .into();
+ let selected_keycode: RcCharacterStyles = RESET_STYLES
+ .foreground(Some(AnsiCode::from(selected_declaration.emphasis_3)))
+ .background(Some(AnsiCode::from(selected_declaration.background)))
+ .bold(Some(AnsiCode::On))
+ .into();
+ GuestModalStyles {
+ fill,
+ title,
+ session,
+ keycode,
+ prompt,
+ option_marker,
+ option_title,
+ selected_marker,
+ selected_title,
+ selected_fill,
+ selected_keycode,
+ }
+}
+
+struct GuestModalRow {
+ segments: Vec<GuestModalSegment>,
+ block_fill: RcCharacterStyles,
+}
+
+struct GuestModalLayout {
+ rows: Vec<GuestModalRow>,
+ block_width: usize,
+ option_rows: Vec<usize>,
+ left_pad: usize,
+ start_row: usize,
+}
+
+fn guest_modal_fallback_layout(
+ columns: usize,
+ rows: usize,
+ styles: &GuestModalStyles,
+ session_name: &str,
+) -> GuestModalLayout {
+ let fallback: Vec<GuestModalSegment> = vec![
+ ("Nested Zellij session: ".to_string(), styles.title.clone()),
+ (session_name.to_string(), styles.session.clone()),
+ (" ".to_string(), styles.fill.clone()),
+ ("<Esc>".to_string(), styles.keycode.clone()),
+ (" dismiss".to_string(), styles.fill.clone()),
+ ];
+ let block_width = guest_modal_row_width(&fallback).min(columns);
+ let left_pad = center_modal_pad(block_width, columns);
+ GuestModalLayout {
+ rows: vec![GuestModalRow {
+ segments: fallback,
+ block_fill: styles.fill.clone(),
+ }],
+ block_width,
+ option_rows: vec![],
+ left_pad,
+ start_row: rows / 2,
+ }
+}
+
+fn guest_modal_layout(
+ columns: usize,
+ rows: usize,
+ styles: &GuestModalStyles,
+ session_name: &str,
+ selected: usize,
+ shortcuts: &GuestModalShortcuts,
+) -> GuestModalLayout {
+ if columns < 20 {
+ return guest_modal_fallback_layout(columns, rows, styles, session_name);
+ }
+
+ let title: Vec<GuestModalSegment> = vec![
+ (
+ "Nested Zellij session detected: ".to_string(),
+ styles.title.clone(),
+ ),
+ (session_name.to_string(), styles.session.clone()),
+ ];
+ let prompt: Vec<GuestModalSegment> = vec![(
+ "What would you like to do?".to_string(),
+ styles.prompt.clone(),
+ )];
+ let options = [
+ ("1. ", "Zoom in and control this session"),
+ ("2. ", "Control this session on focus"),
+ ];
+ let explanation_texts = [
+ "The session will take up the whole screen. Toggle this on/off with",
+ "When this pane gains focus, keybindings will be sent to this session. Ascend to the current session with",
+ ];
+ let shortcut_keys = [&shortcuts.zoom, &shortcuts.ascend];
+ let hint = "<↓↑> select <Enter> confirm <1-2> direct <Esc> dismiss";
+ let hint_segments =
+ guest_modal_hint_segments(hint, styles.fill.clone(), styles.keycode.clone());
+
+ let fixed_lines: Vec<&[GuestModalSegment]> = vec![
+ title.as_slice(),
+ prompt.as_slice(),
+ hint_segments.as_slice(),
+ ];
+ let mut block_width = fixed_lines
+ .iter()
+ .map(|segments| guest_modal_row_width(segments))
+ .max()
+ .unwrap_or(0);
+ for (number, option_title) in options.iter() {
+ block_width = block_width.max(2 + number.width() + option_title.width());
+ }
+ let block_width = block_width.min(columns);
+
+ let mut rows_content: Vec<GuestModalRow> = Vec::new();
+ let mut option_rows: Vec<usize> = Vec::new();
+ rows_content.push(GuestModalRow {
+ segments: title,
+ block_fill: styles.fill.clone(),
+ });
+ rows_content.push(GuestModalRow {
+ segments: Vec::new(),
+ block_fill: styles.fill.clone(),
+ });
+ rows_content.push(GuestModalRow {
+ segments: prompt,
+ block_fill: styles.fill.clone(),
+ });
+ rows_content.push(GuestModalRow {
+ segments: Vec::new(),
+ block_fill: styles.fill.clone(),
+ });
+ for (index, (number, option_title)) in options.iter().enumerate() {
+ let is_selected = index == selected;
+ let (marker_styles, title_styles, text_styles, keycode_styles, block_fill) = if is_selected
+ {
+ (
+ styles.selected_marker.clone(),
+ styles.selected_title.clone(),
+ styles.selected_fill.clone(),
+ styles.selected_keycode.clone(),
+ styles.selected_fill.clone(),
+ )
+ } else {
+ (
+ styles.option_marker.clone(),
+ styles.option_title.clone(),
+ styles.fill.clone(),
+ styles.keycode.clone(),
+ styles.fill.clone(),
+ )
+ };
+ let marker = if is_selected { "> " } else { " " };
+ option_rows.push(rows_content.len());
+ rows_content.push(GuestModalRow {
+ segments: vec![
+ (format!("{}{}", marker, number), marker_styles),
+ (option_title.to_string(), title_styles),
+ ],
+ block_fill: block_fill.clone(),
+ });
+ let mut words = guest_modal_text_words(explanation_texts[index], text_styles.clone());
+ words.extend(guest_modal_keycode_words(
+ shortcut_keys[index],
+ text_styles.clone(),
+ keycode_styles,
+ ));
+ for line in guest_modal_wrap_words(&words, 5, text_styles.clone(), block_width) {
+ rows_content.push(GuestModalRow {
+ segments: line,
+ block_fill: block_fill.clone(),
+ });
+ }
+ }
+ rows_content.push(GuestModalRow {
+ segments: Vec::new(),
+ block_fill: styles.fill.clone(),
+ });
+ rows_content.push(GuestModalRow {
+ segments: hint_segments,
+ block_fill: styles.fill.clone(),
+ });
+
+ let block_height = rows_content.len();
+ if rows < block_height {
+ return guest_modal_fallback_layout(columns, rows, styles, session_name);
+ }
+ let left_pad = center_modal_pad(block_width, columns);
+ let start_row = rows.saturating_sub(block_height) / 2;
+
+ GuestModalLayout {
+ rows: rows_content,
+ block_width,
+ option_rows,
+ left_pad,
+ start_row,
+ }
+}
+
+pub fn guest_modal_option_at_content_row(
+ rows: usize,
+ columns: usize,
+ row: usize,
+ style: &Style,
+ session_name: &str,
+ selected: usize,
+ shortcuts: &GuestModalShortcuts,
+) -> Option<usize> {
+ if columns < 20 {
+ return None;
+ }
+ let styles = guest_modal_styles(style);
+ let layout = guest_modal_layout(columns, rows, &styles, session_name, selected, shortcuts);
+ if layout.option_rows.is_empty() {
+ return None;
+ }
+ layout
+ .option_rows
+ .iter()
+ .position(|&option_row| layout.start_row + option_row == row)
+}
+
+fn center_modal_pad(block_width: usize, columns: usize) -> usize {
+ if block_width >= columns {
+ return 0;
+ }
+ (columns - block_width) / 2
+}
+
+#[cfg(test)]
+#[path = "./unit/nested_session_modal_tests.rs"]
+mod nested_session_modal_tests;
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index 429d025b..cdf269dd 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -3,11 +3,12 @@ use crate::panes::sixel::SixelImageStore;
use crate::panes::LinkHandler;
use crate::panes::{
grid::Grid,
+ nested_session_modal::GuestModalShortcuts,
terminal_character::{render_first_run_banner, TerminalCharacter, EMPTY_TERMINAL_CHARACTER},
};
use crate::pty::VteBytes;
use crate::route::NotificationEnd;
-use crate::tab::{AdjustedInput, Pane};
+use crate::tab::{AdjustedInput, GuestChoiceIndicator, Pane};
use crate::ClientId;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -26,6 +27,7 @@ use zellij_utils::{
},
errors::prelude::*,
input::layout::Run,
+ nested_session::NestedSessionMessage,
pane_size::PaneGeom,
pane_size::SizeInPixels,
position::Position,
@@ -159,6 +161,11 @@ pub struct TerminalPane {
/// stdin in the same stream position the original query occupied.
/// Cleared by Tab when the reply (or 500 ms cache-fallback) lands.
forward_paused: bool,
+ nested_guest: bool,
+ guest_modal: HashMap<ClientId, usize>,
+ guest_choice_indicators: HashMap<ClientId, GuestChoiceIndicator>,
+ guest_session_name: Option<String>,
+ guest_modal_shortcuts: GuestModalShortcuts,
/// PTY bytes that have not yet been fed to vte. Single source of
/// truth: `handle_pty_bytes` always appends here, and processing
/// pops one byte at a time and advances the vte parser. Processing
@@ -234,8 +241,13 @@ impl Pane for TerminalPane {
}
}
}
- fn cursor_coordinates(&self, _client_id: Option<ClientId>) -> Option<(usize, usize, bool)> {
+ fn cursor_coordinates(&self, client_id: Option<ClientId>) -> Option<(usize, usize, bool)> {
// (x, y, is_visible)
+ if let Some(client_id) = client_id {
+ if self.guest_modal.contains_key(&client_id) {
+ return None;
+ }
+ }
if self.get_content_rows() < 1 || self.get_content_columns() < 1 {
// do not render cursor if there's no room for it
return None;
@@ -274,7 +286,72 @@ impl Pane for TerminalPane {
}
}
- if self.is_held.is_some() {
+ if let Some(selection) = client_id.and_then(|c| self.guest_modal.get(&c).copied()) {
+ let client_id = client_id.expect("guest modal selection requires a client id");
+ let is_up = key_with_modifier
+ .as_ref()
+ .map(|k| {
+ k.is_key_without_modifier(BareKey::Up)
+ || k.is_key_without_modifier(BareKey::Char('k'))
+ })
+ .unwrap_or(false)
+ || raw_input_bytes.as_slice() == UP_ARROW;
+ let is_down = key_with_modifier
+ .as_ref()
+ .map(|k| {
+ k.is_key_without_modifier(BareKey::Down)
+ || k.is_key_without_modifier(BareKey::Char('j'))
+ })
+ .unwrap_or(false)
+ || raw_input_bytes.as_slice() == DOWN_ARROW;
+ let is_enter = key_with_modifier
+ .as_ref()
+ .map(|k| k.is_key_without_modifier(BareKey::Enter))
+ .unwrap_or(false)
+ || matches!(
+ raw_input_bytes.as_slice(),
+ ENTER_CARRIAGE_RETURN | ENTER_NEWLINE
+ );
+ let is_esc = key_with_modifier
+ .as_ref()
+ .map(|k| k.is_key_without_modifier(BareKey::Esc))
+ .unwrap_or(false)
+ || raw_input_bytes.as_slice() == ESC;
+ let digit = key_with_modifier
+ .as_ref()
+ .and_then(|k| match k.bare_key {
+ BareKey::Char(c @ '1'..='2') if k.key_modifiers.is_empty() => Some(c),
+ _ => None,
+ })
+ .or_else(|| match raw_input_bytes.as_slice() {
+ b"1" => Some('1'),
+ b"2" => Some('2'),
+ _ => None,
+ });
+ if is_up {
+ self.guest_modal.insert(client_id, (selection + 1) % 2);
+ self.set_should_render(true);
+ Some(AdjustedInput::GuestModalSelectionChanged)
+ } else if is_down {
+ self.guest_modal.insert(client_id, (selection + 1) % 2);
+ self.set_should_render(true);
+ Some(AdjustedInput::GuestModalSelectionChanged)
+ } else if let Some(digit) = digit {
+ match digit {
+ '1' => Some(AdjustedInput::GuestModalZoom),
+ _ => Some(AdjustedInput::GuestModalDescend),
+ }
+ } else if is_enter {
+ match selection {
+ 0 => Some(AdjustedInput::GuestModalZoom),
+ _ => Some(AdjustedInput::GuestModalDescend),
+ }
+ } else if is_esc {
+ Some(AdjustedInput::GuestModalDescend)
+ } else {
+ None
+ }
+ } else if self.is_held.is_some() {
if key_with_modifier
.as_ref()
.map(|k| k.is_key_without_modifier(BareKey::Enter))
@@ -616,6 +693,98 @@ impl Pane for TerminalPane {
self.grid.pending_forwarded_queries.drain(..).collect()
}
+ fn drain_nested_session_messages(&mut self) -> Vec<NestedSessionMessage> {
+ self.grid
+ .pending_nested_session_messages
+ .drain(..)
+ .collect()
+ }
+
+ fn is_nested_guest(&self) -> bool {
+ self.nested_guest
+ }
+
+ fn set_is_nested_guest(&mut self, is_nested_guest: bool) {
+ self.nested_guest = is_nested_guest;
+ }
+
+ fn set_guest_modal(&mut self, client_ids: &[ClientId]) {
+ for client_id in client_ids {
+ self.guest_modal.insert(*client_id, 0);
+ }
+ self.set_should_render(true);
+ }
+
+ fn clear_guest_modal(&mut self, client_id: ClientId) {
+ if self.guest_modal.remove(&client_id).is_some() {
+ self.render_full_viewport();
+ self.set_should_render(true);
+ }
+ }
+
+ fn clear_all_guest_modals(&mut self) {
+ if !self.guest_modal.is_empty() {
+ self.guest_modal.clear();
+ self.render_full_viewport();
+ self.set_should_render(true);
+ }
+ }
+
+ fn guest_modal_selection(&self, client_id: ClientId) -> Option<usize> {
+ self.guest_modal.get(&client_id).copied()
+ }
+
+ fn has_guest_modal_for_any_client(&self) -> bool {
+ !self.guest_modal.is_empty()
+ }
+
+ fn set_guest_choice_indicator(
+ &mut self,
+ client_id: ClientId,
+ indicator: Option<GuestChoiceIndicator>,
+ ) {
+ let previous = self.guest_choice_indicators.get(&client_id).copied();
+ if previous == indicator {
+ return;
+ }
+ match indicator {
+ Some(indicator) => {
+ self.guest_choice_indicators.insert(client_id, indicator);
+ },
+ None => {
+ self.guest_choice_indicators.remove(&client_id);
+ },
+ }
+ self.set_should_render(true);
+ }
+
+ fn guest_choice_indicator(&self, client_id: ClientId) -> Option<GuestChoiceIndicator> {
+ self.guest_choice_indicators.get(&client_id).copied()
+ }
+
+ fn clear_all_guest_choice_indicators(&mut self) {
+ if !self.guest_choice_indicators.is_empty() {
+ self.guest_choice_indicators.clear();
+ self.set_should_render(true);
+ }
+ }
+
+ fn set_guest_session_name(&mut self, session_name: Option<String>) {
+ self.guest_session_name = session_name;
+ }
+
+ fn guest_session_name(&self) -> Option<String> {
+ self.guest_session_name.clone()
+ }
+
+ fn set_guest_modal_shortcuts(&mut self, shortcuts: GuestModalShortcuts) {
+ self.guest_modal_shortcuts = shortcuts;
+ }
+
+ fn guest_modal_shortcuts(&self) -> GuestModalShortcuts {
+ self.guest_modal_shortcuts.clone()
+ }
+
fn arm_forward_pause(&mut self) {
self.forward_paused = true;
}
@@ -1154,6 +1323,11 @@ impl TerminalPane {
arrow_fonts,
notification_end,
forward_paused: false,
+ nested_guest: false,
+ guest_modal: HashMap::new(),
+ guest_choice_indicators: HashMap::new(),
+ guest_session_name: None,
+ guest_modal_shortcuts: GuestModalShortcuts::default(),
pending_pty_input: VecDeque::new(),
}
}
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs
index 9bc67713..c8f260ee 100644
--- a/zellij-server/src/panes/tiled_panes/mod.rs
+++ b/zellij-server/src/panes/tiled_panes/mod.rs
@@ -80,11 +80,13 @@ pub struct TiledPanes {
pane_frame_style: PaneFrameStyle,
panes_to_hide: HashSet<PaneId>,
fullscreen_is_active: Option<PaneId>,
+ fullscreen_covers_ui: Rc<RefCell<bool>>,
senders: ThreadSenders,
window_title: Option<String>,
client_id_to_boundaries: HashMap<ClientId, Boundaries>,
tombstones_before_increase: Option<(PaneId, Vec<HashMap<PaneId, PaneGeom>>)>,
tombstones_before_decrease: Option<(PaneId, Vec<HashMap<PaneId, PaneGeom>>)>,
+ dimmed_clients: HashSet<ClientId>,
}
impl TiledPanes {
@@ -99,6 +101,7 @@ impl TiledPanes {
stacked_resize: Rc<RefCell<bool>>,
stacked_pane_list: Rc<RefCell<bool>>,
reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>>,
+ fullscreen_covers_ui: Rc<RefCell<bool>>,
session_is_mirrored: bool,
pane_frame_style: PaneFrameStyle,
default_mode_info: ModeInfo,
@@ -124,13 +127,23 @@ impl TiledPanes {
pane_frame_style,
panes_to_hide: HashSet::new(),
fullscreen_is_active: None,
+ fullscreen_covers_ui,
senders,
window_title: None,
client_id_to_boundaries: HashMap::new(),
tombstones_before_increase: None,
tombstones_before_decrease: None,
+ dimmed_clients: HashSet::new(),
}
}
+ pub fn set_client_dimmed(&mut self, client_id: ClientId, dimmed: bool) {
+ if dimmed {
+ self.dimmed_clients.insert(client_id);
+ } else {
+ self.dimmed_clients.remove(&client_id);
+ }
+ self.set_force_render();
+ }
pub fn add_pane_with_existing_geom(&mut self, pane_id: PaneId, mut pane: Box<dyn Pane>) {
if self.pane_frame_style.draws_full_frames() {
pane.set_content_offset(Offset::frame(1));
@@ -579,6 +592,11 @@ impl TiledPanes {
.count()
== 1;
let viewport = *self.viewport.borrow();
+ let no_ui_fullscreen_pane_id = if *self.fullscreen_covers_ui.borrow() {
+ self.fullscreen_is_active
+ } else {
+ None
+ };
let position_and_sizes_of_stacks = {
StackedPanes::new_from_btreemap(&mut self.panes, &self.panes_to_hide)
.positions_and_sizes_of_all_stacks()
@@ -590,10 +608,17 @@ impl TiledPanes {
self.reserved_top_rows.borrow().clone()
};
for pane in self.panes.values_mut() {
+ if no_ui_fullscreen_pane_id == Some(pane.pid()) {
+ pane.set_frame(false);
+ pane.set_content_offset(Offset::default());
+ resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal();
+ continue;
+ }
if !pane.borderless() {
pane.set_frame(draws_full_frames);
}
+ let pane_viewport = &viewport;
let reserved_rows = reserved_top_rows.get(&pane.pid()).copied().unwrap_or(0);
#[allow(clippy::if_same_then_else)]
@@ -608,7 +633,7 @@ impl TiledPanes {
} else {
let position_and_size = pane.current_geom();
let (pane_columns_offset, pane_rows_offset) =
- pane_content_offset(&position_and_size, &viewport);
+ pane_content_offset(&position_and_size, pane_viewport);
let visible_member_title_rows = if draws_full_frames { 1 } else { 0 };
pane.set_content_offset(Offset {
top: visible_member_title_rows + reserved_rows,
@@ -621,7 +646,7 @@ impl TiledPanes {
pane.set_content_offset(Offset::frame(1));
} else if draws_full_frames && pane.borderless() {
pane.set_content_offset(Offset::default());
- } else if !is_inside_viewport(&viewport, pane) {
+ } else if !is_inside_viewport(pane_viewport, pane) {
pane.set_content_offset(Offset::default());
} else {
let mut position_and_size = pane.current_geom();
@@ -635,7 +660,7 @@ impl TiledPanes {
position_and_size = *position_and_size_of_stack;
};
let (pane_columns_offset, pane_rows_offset) =
- pane_content_offset(&position_and_size, &viewport);
+ pane_content_offset(&position_and_size, pane_viewport);
let reserve_title_row = if draws_titles {
!pane_is_borderless && is_flexible && !single_selectable_tiled_pane
} else {
@@ -652,7 +677,7 @@ impl TiledPanes {
}
}
- resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).unwrap();
+ resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal();
}
self.reset_boundaries();
}
@@ -1180,7 +1205,10 @@ impl TiledPanes {
stacked_pane_ids_on_top_of_stacks.contains(&pane.pid());
let pane_is_on_bottom_of_stack =
stacked_pane_ids_on_bottom_of_stacks.contains(&pane.pid());
- let should_draw_pane_frames = self.pane_frame_style.draws_full_frames();
+ let pane_is_no_ui_fullscreen = *self.fullscreen_covers_ui.borrow()
+ && self.fullscreen_is_active == Some(pane.pid());
+ let should_draw_pane_frames =
+ self.pane_frame_style.draws_full_frames() && !pane_is_no_ui_fullscreen;
let pane_is_stacked = pane.current_geom().is_stacked();
let pane_is_one_liner_in_stack =
pane_is_stacked && pane.current_geom().rows.is_fixed();
@@ -1203,6 +1231,7 @@ impl TiledPanes {
} else {
None
};
+ let pane_has_guest_modal = pane.has_guest_modal_for_any_client();
let mut pane_contents_and_ui = PaneContentsAndUi::new(
pane,
output,
@@ -1218,6 +1247,7 @@ impl TiledPanes {
show_help_text,
omit_pane_title && reserved_rows_for_pane == 0,
mouse_scroll_resize,
+ self.dimmed_clients.clone(),
);
pane_contents_and_ui.set_frame_geom_override(visible_member_frame_override);
pane_contents_and_ui.set_blank_title(reserved_rows_for_pane > 0);
@@ -1238,7 +1268,8 @@ impl TiledPanes {
}
}
let is_floating = false;
- if self.pane_frame_style.draws_full_frames() {
+ if pane_is_no_ui_fullscreen {
+ } else if self.pane_frame_style.draws_full_frames() {
pane_contents_and_ui
.render_pane_frame(
*client_id,
@@ -1297,21 +1328,45 @@ impl TiledPanes {
}
if let PaneId::Terminal(..) = kind {
if !pane_is_one_liner_in_stack {
- pane_contents_and_ui
- .render_pane_contents_to_multiple_clients(
- connected_clients.iter().copied(),
- )
- .with_context(err_context)?;
+ if pane_has_guest_modal {
+ let (modal_clients, plain_clients): (Vec<ClientId>, Vec<ClientId>) =
+ connected_clients.iter().copied().partition(|client_id| {
+ pane_contents_and_ui.client_has_guest_modal(*client_id)
+ });
+ if !plain_clients.is_empty() {
+ pane_contents_and_ui
+ .render_pane_contents_to_multiple_clients(
+ plain_clients.iter().copied(),
+ )
+ .with_context(err_context)?;
+ } else {
+ pane_contents_and_ui.drain_pane_render_state();
+ }
+ for client_id in modal_clients {
+ pane_contents_and_ui
+ .render_guest_modal_for_client(client_id)
+ .with_context(err_context)?;
+ }
+ } else {
+ pane_contents_and_ui
+ .render_pane_contents_to_multiple_clients(
+ connected_clients.iter().copied(),
+ )
+ .with_context(err_context)?;
+ }
}
}
}
}
// render boundaries if needed
for (client_id, boundaries) in client_id_to_boundaries {
- let boundaries_to_render = boundaries
+ let mut boundaries_to_render = boundaries
.render(self.client_id_to_boundaries.get(&client_id))
.with_context(err_context)?;
self.client_id_to_boundaries.insert(client_id, boundaries);
+ if self.dimmed_clients.contains(&client_id) {
+ crate::ui::pane_contents_and_ui::dim_character_chunks(&mut boundaries_to_render);
+ }
output
.add_character_chunks_to_client(client_id, boundaries_to_render, None)
.with_context(err_context)?;
@@ -1889,7 +1944,7 @@ impl TiledPanes {
for pane in self.panes.values_mut() {
// TODO: only for the panes whose width/height actually changed
- resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).unwrap();
+ resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal();
}
self.reset_boundaries();
Ok(pane_size_changed)
@@ -1917,7 +1972,7 @@ impl TiledPanes {
}
for pane in self.panes.values_mut() {
- resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).unwrap();
+ resize_pty!(pane, self.os_api, self.senders, self.character_cell_size).non_fatal();
}
self.reset_boundaries();
Ok(())
@@ -2059,6 +2114,44 @@ impl TiledPanes {
self.focus_pane(next_index, client_id);
self.set_pane_active_at(next_index);
}
+ pub fn focus_pane_adjacent_to(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ client_id: ClientId,
+ ) -> Option<PaneId> {
+ if !self.panes.contains_key(&pane_id) {
+ return None;
+ }
+ let pane_grid = TiledPaneGrid::new(
+ &mut self.panes,
+ &self.panes_to_hide,
+ *self.display_area.borrow(),
+ *self.viewport.borrow(),
+ );
+ let next_index = match direction {
+ Direction::Left => pane_grid.next_selectable_pane_id_to_the_left(&pane_id),
+ Direction::Down => pane_grid.next_selectable_pane_id_below(&pane_id, false),
+ Direction::Up => pane_grid.next_selectable_pane_id_above(&pane_id, false),
+ Direction::Right => pane_grid.next_selectable_pane_id_to_the_right(&pane_id),
+ }?;
+ if let Some(previously_active_pane) = self
+ .active_panes
+ .get(&client_id)
+ .copied()
+ .and_then(|active_pane_id| self.panes.get_mut(&active_pane_id))
+ {
+ previously_active_pane.set_should_render(true);
+ previously_active_pane.render_full_viewport();
+ }
+ if let Some(next_active_pane) = self.panes.get_mut(&next_index) {
+ next_active_pane.set_should_render(true);
+ next_active_pane.render_full_viewport();
+ }
+ self.focus_pane(next_index, client_id);
+ self.set_pane_active_at(next_index);
+ Some(next_index)
+ }
pub fn move_focus_left(&mut self, client_id: ClientId) -> bool {
match self.get_active_pane_id(client_id) {
Some(active_pane_id) => {
@@ -2674,6 +2767,9 @@ impl TiledPanes {
pub fn fullscreen_pane_id(&self) -> Option<PaneId> {
self.fullscreen_is_active
}
+ pub fn fullscreen_covers_ui(&self) -> bool {
+ *self.fullscreen_covers_ui.borrow()
+ }
pub fn unset_fullscreen(&mut self) {
if let Some(fullscreen_pane_id) = self.fullscreen_is_active {
let panes_to_hide: Vec<_> = self.panes_to_hide.iter().copied().collect();
@@ -2699,9 +2795,8 @@ impl TiledPanes {
if let Some(fullscreen_pane) = self.get_pane_mut(fullscreen_pane_id) {
fullscreen_pane.reset_size_and_position_override();
}
- self.set_force_render();
- let display_area = *self.display_area.borrow();
- self.resize(display_area);
+ *self.fullscreen_covers_ui.borrow_mut() = false;
+ self.rerun_layout_solver_and_rerender();
self.fullscreen_is_active = None;
}
}
@@ -2711,105 +2806,180 @@ impl TiledPanes {
}
}
+ pub fn toggle_active_pane_no_ui_fullscreen(&mut self, client_id: ClientId) {
+ if let Some(active_pane_id) = self.get_active_pane_id(client_id) {
+ self.toggle_pane_no_ui_fullscreen(active_pane_id);
+ }
+ }
+
pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) {
if self.fullscreen_is_active.is_some() {
self.unset_fullscreen();
} else {
- let pane_ids_to_hide = self.panes.iter().filter_map(|(&id, _pane)| {
- if id != pane_id
- && is_inside_viewport(&*self.viewport.borrow(), self.get_pane(id).unwrap())
- {
- Some(id)
- } else {
- None
- }
- });
- self.panes_to_hide = pane_ids_to_hide.collect();
- if self.panes_to_hide.is_empty() {
- // nothing to do, pane is already as fullscreen as it can be, let's bail
- return;
- } else {
- // For all of the panes outside of the viewport staying on the fullscreen
- // screen, switch them to using override positions as well so that the resize
- // system doesn't get confused by viewport and old panes that no longer line up
- let viewport_pane_ids: Vec<_> = self
- .panes
- .keys()
- .copied()
- .into_iter()
- .filter(|id| {
- !is_inside_viewport(&*self.viewport.borrow(), self.get_pane(*id).unwrap())
- })
- .collect();
- for pid in viewport_pane_ids {
- if let Some(viewport_pane) = self.get_pane_mut(pid) {
- viewport_pane.set_geom_override(viewport_pane.position_and_size());
- }
- }
- let viewport = { *self.viewport.borrow() };
- if let Some(active_pane) = self.get_pane_mut(pane_id) {
- let full_screen_geom = PaneGeom {
- x: viewport.x,
- y: viewport.y,
- ..Default::default()
- };
- active_pane.set_geom_override(full_screen_geom);
- }
+ self.set_fullscreen(pane_id, false);
+ }
+ }
+
+ pub fn toggle_pane_no_ui_fullscreen(&mut self, pane_id: PaneId) {
+ if self.fullscreen_is_active.is_some() {
+ let was_no_ui = *self.fullscreen_covers_ui.borrow();
+ self.unset_fullscreen();
+ if !was_no_ui {
+ self.set_fullscreen(pane_id, true);
}
- let connected_client_list: Vec<ClientId> =
- { self.connected_clients.borrow().iter().copied().collect() };
- for client_id in connected_client_list {
- self.focus_pane(pane_id, client_id);
+ } else {
+ self.set_fullscreen(pane_id, true);
+ }
+ }
+
+ fn set_fullscreen(&mut self, pane_id: PaneId, covers_ui: bool) {
+ self.panes_to_hide = self.panes_covered_by_fullscreen(pane_id, covers_ui);
+ if self.panes_to_hide.is_empty() && !covers_ui {
+ return;
+ }
+ if covers_ui {
+ self.expand_pane_over_whole_display(pane_id);
+ } else {
+ self.pin_panes_outside_viewport_in_place();
+ self.expand_pane_over_whole_viewport(pane_id);
+ }
+ self.focus_pane_for_all_clients(pane_id);
+ self.fullscreen_is_active = Some(pane_id);
+ *self.fullscreen_covers_ui.borrow_mut() = covers_ui;
+ self.rerun_layout_solver_and_rerender();
+ }
+
+ fn panes_covered_by_fullscreen(
+ &self,
+ fullscreen_pane_id: PaneId,
+ covers_ui: bool,
+ ) -> HashSet<PaneId> {
+ self.panes
+ .keys()
+ .copied()
+ .filter(|&id| {
+ id != fullscreen_pane_id
+ && (covers_ui
+ || is_inside_viewport(&*self.viewport.borrow(), self.get_pane(id).unwrap()))
+ })
+ .collect()
+ }
+
+ fn pin_panes_outside_viewport_in_place(&mut self) {
+ let pane_ids_outside_viewport: Vec<_> = self
+ .panes
+ .keys()
+ .copied()
+ .filter(|id| !is_inside_viewport(&*self.viewport.borrow(), self.get_pane(*id).unwrap()))
+ .collect();
+ for pane_id in pane_ids_outside_viewport {
+ if let Some(pane) = self.get_pane_mut(pane_id) {
+ pane.set_geom_override(pane.position_and_size());
}
- self.set_force_render();
- let display_area = *self.display_area.borrow();
- self.resize(display_area);
- self.fullscreen_is_active = Some(pane_id);
+ }
+ }
+
+ fn expand_pane_over_whole_display(&mut self, pane_id: PaneId) {
+ let display_area = { *self.display_area.borrow() };
+ self.expand_pane_over_area(pane_id, 0, 0, display_area.rows, display_area.cols);
+ }
+
+ fn expand_pane_over_whole_viewport(&mut self, pane_id: PaneId) {
+ let viewport = { *self.viewport.borrow() };
+ self.expand_pane_over_area(
+ pane_id,
+ viewport.x,
+ viewport.y,
+ viewport.rows,
+ viewport.cols,
+ );
+ }
+
+ fn expand_pane_over_area(
+ &mut self,
+ pane_id: PaneId,
+ x: usize,
+ y: usize,
+ rows: usize,
+ cols: usize,
+ ) {
+ if let Some(pane) = self.get_pane_mut(pane_id) {
+ let mut expanded_geom = PaneGeom {
+ x,
+ y,
+ ..Default::default()
+ };
+ expanded_geom.rows.set_inner(rows);
+ expanded_geom.cols.set_inner(cols);
+ pane.set_geom_override(expanded_geom);
+ }
+ }
+
+ fn rerun_layout_solver_and_rerender(&mut self) {
+ self.set_force_render();
+ let display_area = *self.display_area.borrow();
+ self.resize(display_area);
+ }
+
+ fn reenter_active_pane_fullscreen(&mut self, client_id: ClientId, covers_ui: bool) {
+ if covers_ui {
+ self.toggle_active_pane_no_ui_fullscreen(client_id);
+ } else {
+ self.toggle_active_pane_fullscreen(client_id);
}
}
pub fn focus_pane_left_fullscreen(&mut self, client_id: ClientId) -> bool {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
let ret = self.move_focus_left(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
return ret;
}
pub fn focus_pane_right_fullscreen(&mut self, client_id: ClientId) -> bool {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
let ret = self.move_focus_right(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
return ret;
}
- pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) {
+ pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) -> bool {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
- self.move_focus_up(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ let ret = self.move_focus_up(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
+ return ret;
}
- pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) {
+ pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) -> bool {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
- self.move_focus_down(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ let ret = self.move_focus_down(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
+ return ret;
}
pub fn switch_next_pane_fullscreen(&mut self, client_id: ClientId) {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
self.focus_next_pane(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
}
pub fn switch_prev_pane_fullscreen(&mut self, client_id: ClientId) {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
self.focus_previous_pane(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
}
pub fn switch_last_pane_fullscreen(&mut self, client_id: ClientId) {
+ let covers_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
self.focus_last_pane(client_id);
- self.toggle_active_pane_fullscreen(client_id);
+ self.reenter_active_pane_fullscreen(client_id, covers_ui);
}
pub fn panes_to_hide_count(&self) -> usize {
diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs
index e21ea867..2029a4b4 100644
--- a/zellij-server/src/panes/unit/grid_tests.rs
+++ b/zellij-server/src/panes/unit/grid_tests.rs
@@ -6289,3 +6289,185 @@ fn csi_5n_status_query_still_handled_locally() {
"DSR 5 must still produce its local 'all good' reply"
);
}
+
+fn new_grid_for_nested_frames() -> Grid {
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ Grid::new(
+ 41,
+ 110,
+ Rc::new(RefCell::new(Palette::default())),
+ terminal_emulator_color_codes,
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(None)),
+ sixel_image_store,
+ Style::default(),
+ false,
+ true,
+ true,
+ true,
+ false,
+ )
+}
+
+fn nested_announce_message() -> zellij_utils::nested_session::NestedSessionMessage {
+ zellij_utils::nested_session::NestedSessionMessage::Announce {
+ session_name: "guest-session".to_owned(),
+ capabilities: vec![zellij_utils::nested_session::NestedSessionCapability::NestedControl],
+ }
+}
+
+#[test]
+fn nested_session_frame_is_staged_for_dispatch() {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ let message = nested_announce_message();
+ for byte in zellij_utils::nested_session::encode_frame(&message) {
+ vte_parser.advance(&mut grid, byte);
+ }
+ assert_eq!(grid.pending_nested_session_messages, vec![message]);
+}
+
+#[test]
+fn nested_session_frame_split_across_feeds_is_staged() {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ let message = nested_announce_message();
+ let frame = zellij_utils::nested_session::encode_frame(&message);
+ let (first_half, second_half) = frame.split_at(frame.len() / 2);
+ for byte in first_half {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_nested_session_messages.is_empty());
+ for byte in second_half {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ assert_eq!(grid.pending_nested_session_messages, vec![message]);
+}
+
+#[test]
+fn dcs_with_wrong_magic_param_is_not_staged() {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ for byte in b"\x1bP26662nAAAA\x1b\\" {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_nested_session_messages.is_empty());
+}
+
+#[test]
+fn dcs_with_intermediates_is_not_staged() {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ for byte in b"\x1bP26661$nAAAA\x1b\\" {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_nested_session_messages.is_empty());
+}
+
+#[test]
+fn dcs_n_without_params_is_not_staged() {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ for byte in b"\x1bPnAAAA\x1b\\" {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_nested_session_messages.is_empty());
+}
+
+#[test]
+fn nested_session_frame_with_garbage_payload_is_dropped() {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ for byte in b"\x1bP26661n!!!not-base64!!!\x1b\\" {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_nested_session_messages.is_empty());
+}
+
+fn serialize_text_bytes(text: &str) -> String {
+ text.as_bytes()
+ .iter()
+ .map(|byte| byte.to_string())
+ .collect::<Vec<_>>()
+ .join(",")
+}
+
+fn render_ui_component(component_name: &str, serialized_params: &str) -> Grid {
+ let mut vte_parser = vte::Parser::new();
+ let mut grid = new_grid_for_nested_frames();
+ let dcs = format!("\u{1b}Pz{};{}\u{1b}\\", component_name, serialized_params);
+ for byte in dcs.bytes() {
+ vte_parser.advance(&mut grid, byte);
+ }
+ grid
+}
+
+fn styled_cells_of(
+ grid: &Grid,
+ needle: char,
+) -> Vec<crate::panes::terminal_character::CharacterStyles> {
+ let mut styles = vec![];
+ for line in grid.as_character_lines() {
+ for terminal_character in line {
+ if terminal_character.character == needle {
+ styles.push(*terminal_character.styles);
+ }
+ }
+ }
+ styles
+}
+
+#[test]
+fn disabled_ribbon_renders_italic_non_bold_unselected() {
+ use crate::panes::terminal_character::AnsiCode;
+ let grid = render_ui_component("ribbon", &format!("d{}", serialize_text_bytes("RES")));
+ let letter_styles = styled_cells_of(&grid, 'R');
+ assert!(!letter_styles.is_empty());
+ for style in letter_styles {
+ assert_eq!(style.italic, Some(AnsiCode::On));
+ assert_ne!(style.bold, Some(AnsiCode::On));
+ }
+}
+
+#[test]
+fn disabled_text_with_opaque_flag_keeps_a_background() {
+ use crate::panes::terminal_character::AnsiCode;
+ let grid = render_ui_component("text", &format!("zd{}", serialize_text_bytes("RES")));
+ let letter_styles = styled_cells_of(&grid, 'R');
+ assert!(!letter_styles.is_empty());
+ for style in letter_styles {
+ assert_eq!(style.italic, Some(AnsiCode::On));
+ assert_ne!(style.bold, Some(AnsiCode::On));
+ assert!(style.background.is_some());
+ }
+}
+
+#[test]
+fn disabled_text_renders_italic_non_bold() {
+ use crate::panes::terminal_character::AnsiCode;
+ let grid = render_ui_component("text", &format!("d{}", serialize_text_bytes("RES")));
+ let letter_styles = styled_cells_of(&grid, 'R');
+ assert!(!letter_styles.is_empty());
+ for style in letter_styles {
+ assert_eq!(style.italic, Some(AnsiCode::On));
+ assert_ne!(style.bold, Some(AnsiCode::On));
+ }
+}
+
+#[test]
+fn ui_component_flag_prefixes_parse_order_independently() {
+ let baseline = render_ui_component("text", &format!("xzd{}", serialize_text_bytes("RES")));
+ let baseline_styles = styled_cells_of(&baseline, 'R');
+ for prefix in ["xdz", "zxd", "zdx", "dxz", "dzx"] {
+ let grid = render_ui_component(
+ "text",
+ &format!("{}{}", prefix, serialize_text_bytes("RES")),
+ );
+ assert_eq!(
+ styled_cells_of(&grid, 'R'),
+ baseline_styles,
+ "prefix {prefix} should parse the same flags as xzd"
+ );
+ }
+}
diff --git a/zellij-server/src/panes/unit/nested_session_modal_tests.rs b/zellij-server/src/panes/unit/nested_session_modal_tests.rs
new file mode 100644
index 00000000..4d0a14ee
--- /dev/null
+++ b/zellij-server/src/panes/unit/nested_session_modal_tests.rs
@@ -0,0 +1,360 @@
+use super::*;
+use zellij_utils::data::{PaletteColor, Style};
+
+fn style_with_distinct_selected_background() -> Style {
+ let mut style = Style::default();
+ style.colors.text_selected.background = PaletteColor::EightBit(200);
+ style.colors.text_selected.base = PaletteColor::EightBit(201);
+ style.colors.text_selected.emphasis_1 = PaletteColor::EightBit(202);
+ style.colors.text_selected.emphasis_3 = PaletteColor::EightBit(203);
+ style
+}
+
+fn sample_shortcuts() -> GuestModalShortcuts {
+ GuestModalShortcuts {
+ zoom: vec!["Ctrl g".to_string(), "o".to_string(), "f".to_string()],
+ ascend: vec!["Ctrl o".to_string()],
+ descend: vec!["Ctrl o".to_string(), "d".to_string()],
+ }
+}
+
+fn render_lines(chunks: &[CharacterChunk]) -> Vec<String> {
+ let mut lines: Vec<String> = Vec::new();
+ for chunk in chunks {
+ let text: String = chunk
+ .terminal_characters
+ .iter()
+ .map(|c| c.character)
+ .collect();
+ lines.push(text);
+ }
+ lines
+}
+
+fn rendered_text(chunks: &[CharacterChunk]) -> String {
+ chunks
+ .iter()
+ .flat_map(|chunk| chunk.terminal_characters.iter().map(|c| c.character))
+ .collect()
+}
+
+#[test]
+fn renders_all_expected_content() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "my-session", 1, &shortcuts);
+ assert_eq!(chunks.len(), 30);
+ let rendered = rendered_text(&chunks);
+ assert!(rendered.contains("Nested Zellij session detected: my-session"));
+ assert!(rendered.contains("What would you like to do?"));
+ assert!(rendered.contains("Zoom in and control this session"));
+ assert!(rendered.contains("Control this session on focus"));
+ assert!(rendered.contains("<↓↑> select"));
+ assert!(rendered.contains("<Enter> confirm"));
+ assert!(rendered.contains("<Esc> dismiss"));
+}
+
+#[test]
+fn renders_resolved_keybindings_as_joined_tokens() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "s", 0, &shortcuts);
+ let rendered = rendered_text(&chunks);
+ assert!(rendered.contains("<Ctrl g> + <o> + <f>"));
+ assert!(rendered.contains("<Ctrl o>"));
+}
+
+#[test]
+fn renders_unbound_when_no_keys() {
+ let style = Style::default();
+ let shortcuts = GuestModalShortcuts::default();
+ let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "s", 0, &shortcuts);
+ let rendered = rendered_text(&chunks);
+ assert!(rendered.contains("<unbound>"));
+}
+
+#[test]
+fn selection_marker_moves_with_selected_index() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+
+ let first = render_lines(&guest_modal_chunks(
+ 80, 30, 0, 0, &style, "s", 0, &shortcuts,
+ ));
+ assert!(first
+ .iter()
+ .any(|line| line.trim_start().starts_with("> 1.")));
+ assert!(first.iter().any(|line| line.trim_start().starts_with("2.")));
+ assert!(!first
+ .iter()
+ .any(|line| line.trim_start().starts_with("> 2.")));
+
+ let second = render_lines(&guest_modal_chunks(
+ 80, 30, 0, 0, &style, "s", 1, &shortcuts,
+ ));
+ assert!(second
+ .iter()
+ .any(|line| line.trim_start().starts_with("> 2.")));
+ assert!(!second
+ .iter()
+ .any(|line| line.trim_start().starts_with("> 1.")));
+}
+
+fn number_column(line: &str, number: &str) -> usize {
+ line.find(number).expect("option number present")
+}
+
+#[test]
+fn options_are_left_justified_within_the_centered_block() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let chunks = guest_modal_chunks(80, 30, 0, 0, &style, "s", 0, &shortcuts);
+ let lines = render_lines(&chunks);
+ let selected_line = lines
+ .iter()
+ .find(|line| line.trim_start().starts_with("> 1."))
+ .expect("selected option line present");
+ let selected_number_column = number_column(selected_line, "1.");
+ assert!(
+ selected_number_column > 2,
+ "block must be centered with a left pad before the marker"
+ );
+
+ for (number, needle) in [("2.", "2.")] {
+ let line = lines
+ .iter()
+ .find(|line| line.trim_start().starts_with(needle))
+ .expect("unselected option line present");
+ assert_eq!(
+ number_column(line, number),
+ selected_number_column,
+ "the option number column is stable across selection"
+ );
+ }
+}
+
+#[test]
+fn small_columns_render_the_fallback() {
+ let style = Style::default();
+ let shortcuts = GuestModalShortcuts::default();
+ let chunks = guest_modal_chunks(18, 20, 0, 0, &style, "s", 0, &shortcuts);
+ assert_eq!(chunks.len(), 20);
+ let rendered = rendered_text(&chunks);
+ assert!(rendered.contains("Nested"));
+ assert!(!rendered.contains("What would you like to do?"));
+}
+
+#[test]
+fn small_rows_render_the_fallback() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let chunks = guest_modal_chunks(80, 5, 0, 0, &style, "s", 0, &shortcuts);
+ assert_eq!(chunks.len(), 5);
+ let rendered = rendered_text(&chunks);
+ assert!(rendered.contains("Nested Zellij session"));
+ assert!(!rendered.contains("What would you like to do?"));
+}
+
+#[test]
+fn tiny_geometry_does_not_panic() {
+ let style = Style::default();
+ let shortcuts = GuestModalShortcuts::default();
+ let chunks = guest_modal_chunks(3, 2, 0, 0, &style, "session", 0, &shortcuts);
+ assert_eq!(chunks.len(), 2);
+ let empty = guest_modal_chunks(0, 0, 0, 0, &style, "session", 0, &shortcuts);
+ assert!(empty.is_empty());
+}
+
+#[test]
+fn every_rendered_row_spans_the_full_width() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let columns = 80;
+ let chunks = guest_modal_chunks(columns, 30, 0, 0, &style, "s", 0, &shortcuts);
+ for chunk in &chunks {
+ let width: usize = chunk
+ .terminal_characters
+ .iter()
+ .map(|c| c.character.width().unwrap_or(0).max(1))
+ .sum();
+ assert_eq!(width, columns);
+ }
+}
+
+#[test]
+fn hit_test_round_trips_the_options() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let rows = 40;
+ let columns = 80;
+ let mut hit_rows: Vec<usize> = Vec::new();
+ for row in 0..rows {
+ if let Some(option) =
+ guest_modal_option_at_content_row(rows, columns, row, &style, "session", 0, &shortcuts)
+ {
+ assert_eq!(hit_rows.len(), option);
+ hit_rows.push(row);
+ }
+ }
+ assert_eq!(hit_rows.len(), 2);
+}
+
+#[test]
+fn hit_test_returns_none_off_option_rows_and_in_fallback() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ assert_eq!(
+ guest_modal_option_at_content_row(40, 10, 0, &style, "session", 0, &shortcuts),
+ None
+ );
+ assert_eq!(
+ guest_modal_option_at_content_row(4, 10, 2, &style, "session", 0, &shortcuts),
+ None
+ );
+ assert_eq!(
+ guest_modal_option_at_content_row(40, 80, 0, &style, "session", 0, &shortcuts),
+ None
+ );
+}
+
+#[test]
+fn hit_test_matches_rendered_marker_rows() {
+ let style = Style::default();
+ let shortcuts = sample_shortcuts();
+ let rows = 40;
+ let columns = 80;
+ let selected = 1;
+ let lines = render_lines(&guest_modal_chunks(
+ columns, rows, 0, 0, &style, "session", selected, &shortcuts,
+ ));
+ for (row, line) in lines.iter().enumerate() {
+ let hit = guest_modal_option_at_content_row(
+ rows, columns, row, &style, "session", selected, &shortcuts,
+ );
+ let trimmed = line.trim_start();
+ let is_option_line =
+ trimmed.starts_with("> ") || trimmed.starts_with("1.") || trimmed.starts_with("2.");
+ assert_eq!(hit.is_some(), is_option_line, "row {} mismatch", row);
+ }
+}
+
+#[test]
+fn selected_rows_carry_the_selected_background_rectangle() {
+ let style = style_with_distinct_selected_background();
+ let shortcuts = sample_shortcuts();
+ let selected = 0;
+ let columns = 80;
+ let rows = 40;
+ let styles = guest_modal_styles(&style);
+ let layout = guest_modal_layout(columns, rows, &styles, "session", selected, &shortcuts);
+ let selected_option_offset = layout.option_rows[selected];
+
+ let chunks = guest_modal_chunks(columns, rows, 0, 0, &style, "session", selected, &shortcuts);
+ let selected_row_index = layout.start_row + selected_option_offset;
+ let selected_chunk = &chunks[selected_row_index];
+
+ let selected_background = styles.selected_fill.background;
+ let base_background = styles.fill.background;
+ assert_ne!(
+ selected_background, base_background,
+ "the test style must give the selected background a distinct value"
+ );
+
+ let block_start = layout.left_pad;
+ let block_end = layout.left_pad + layout.block_width;
+ let mut block_cells = 0;
+ for (column, character) in selected_chunk.terminal_characters.iter().enumerate() {
+ if column >= block_start && column < block_end {
+ assert_eq!(
+ character.styles.background, selected_background,
+ "block cell {} on the selected row must carry the selected background",
+ column
+ );
+ block_cells += 1;
+ } else {
+ assert_eq!(
+ character.styles.background, base_background,
+ "outer padding cell {} must keep the base background",
+ column
+ );
+ }
+ }
+ assert_eq!(
+ block_cells, layout.block_width,
+ "the selected rectangle spans the whole block width"
+ );
+}
+
+#[test]
+fn unselected_rows_do_not_use_the_selected_background() {
+ let style = style_with_distinct_selected_background();
+ let shortcuts = sample_shortcuts();
+ let selected = 0;
+ let columns = 80;
+ let rows = 40;
+ let styles = guest_modal_styles(&style);
+ let layout = guest_modal_layout(columns, rows, &styles, "session", selected, &shortcuts);
+ let other_option_offset = layout.option_rows[1];
+
+ let selected_background = styles.selected_fill.background;
+ let chunks = guest_modal_chunks(columns, rows, 0, 0, &style, "session", selected, &shortcuts);
+ let other_row = &chunks[layout.start_row + other_option_offset];
+ for character in &other_row.terminal_characters {
+ assert_ne!(
+ character.styles.background, selected_background,
+ "unselected option row must not carry the selected background"
+ );
+ }
+}
+
+#[test]
+fn keycode_words_join_with_plus_separators() {
+ let style = Style::default();
+ let styles = guest_modal_styles(&style);
+ let keys = vec!["Ctrl g".to_string(), "o".to_string()];
+ let words = guest_modal_keycode_words(&keys, styles.fill.clone(), styles.keycode.clone());
+ let joined: String = words.iter().map(|(text, _)| text.as_str()).collect();
+ assert_eq!(joined, "<Ctrl g>+<o>");
+ assert_eq!(words[0].1, styles.keycode);
+ assert_eq!(words[1].1, styles.fill);
+ assert_eq!(words[2].1, styles.keycode);
+}
+
+#[test]
+fn keycode_words_render_unbound_when_empty() {
+ let style = Style::default();
+ let styles = guest_modal_styles(&style);
+ let words = guest_modal_keycode_words(&[], styles.fill.clone(), styles.keycode.clone());
+ assert_eq!(words.len(), 1);
+ assert_eq!(words[0].0, "<unbound>");
+ assert_eq!(words[0].1, styles.fill);
+}
+
+#[test]
+fn wrap_words_respects_width_and_indent() {
+ let style = Style::default();
+ let styles = guest_modal_styles(&style);
+ let words = guest_modal_text_words("one two three four five", styles.fill.clone());
+ let indent = 4;
+ let width = 14;
+ let lines = guest_modal_wrap_words(&words, indent, styles.fill.clone(), width);
+ assert!(lines.len() > 1, "text wider than the width must wrap");
+ for line in &lines {
+ let indent_segment = &line[0];
+ assert_eq!(indent_segment.0, " ".repeat(indent));
+ let rendered_width: usize = line.iter().map(|(text, _)| text.width()).sum();
+ assert!(
+ rendered_width <= width,
+ "wrapped line width {} exceeds {}",
+ rendered_width,
+ width
+ );
+ }
+}
+
+#[test]
+fn center_pad_centers_and_clamps() {
+ assert_eq!(center_modal_pad(10, 30), 10);
+ assert_eq!(center_modal_pad(30, 30), 0);
+ assert_eq!(center_modal_pad(40, 30), 0);
+}
diff --git a/zellij-server/src/panes/unit/terminal_pane_tests.rs b/zellij-server/src/panes/unit/terminal_pane_tests.rs
index 2e34b273..a6234844 100644
--- a/zellij-server/src/panes/unit/terminal_pane_tests.rs
+++ b/zellij-server/src/panes/unit/terminal_pane_tests.rs
@@ -873,3 +873,150 @@ pub fn frameless_pane_position_is_on_frame() {
assert!(!terminal_pane.position_is_on_frame(&Position::new(30, 130)));
assert!(!terminal_pane.position_is_on_frame(&Position::new(30, 131)));
}
+
+fn create_guest_modal_pane() -> TerminalPane {
+ let mut fake_win_size = PaneGeom::default();
+ fake_win_size.cols.set_inner(80);
+ fake_win_size.rows.set_inner(24);
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ TerminalPane::new(
+ 1,
+ fake_win_size,
+ Style::default(),
+ 0,
+ String::new(),
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(None)),
+ sixel_image_store,
+ terminal_emulator_colors,
+ terminal_emulator_color_codes,
+ None,
+ None,
+ false,
+ true,
+ true,
+ true,
+ false,
+ None,
+ )
+}
+
+fn press_key(
+ pane: &mut TerminalPane,
+ bare_key: zellij_utils::data::BareKey,
+ client_id: u16,
+) -> Option<crate::tab::AdjustedInput> {
+ let key = zellij_utils::data::KeyWithModifier::new(bare_key);
+ pane.adjust_input_to_terminal(&Some(key), vec![], false, Some(client_id))
+}
+
+#[test]
+pub fn guest_modal_navigation_wraps() {
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+ pane.set_guest_modal(&[client_id]);
+ assert_eq!(pane.guest_modal_selection(client_id), Some(0));
+ press_key(&mut pane, BareKey::Up, client_id);
+ assert_eq!(pane.guest_modal_selection(client_id), Some(1));
+ press_key(&mut pane, BareKey::Down, client_id);
+ assert_eq!(pane.guest_modal_selection(client_id), Some(0));
+ press_key(&mut pane, BareKey::Down, client_id);
+ assert_eq!(pane.guest_modal_selection(client_id), Some(1));
+ press_key(&mut pane, BareKey::Up, client_id);
+ assert_eq!(pane.guest_modal_selection(client_id), Some(0));
+}
+
+#[test]
+pub fn guest_modal_tab_is_ignored() {
+ use zellij_utils::data::{BareKey, KeyWithModifier};
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+ pane.set_guest_modal(&[client_id]);
+ press_key(&mut pane, BareKey::Tab, client_id);
+ assert_eq!(pane.guest_modal_selection(client_id), Some(0));
+ let key = KeyWithModifier::new(BareKey::Tab).with_shift_modifier();
+ pane.adjust_input_to_terminal(&Some(key), vec![], false, Some(client_id));
+ assert_eq!(pane.guest_modal_selection(client_id), Some(0));
+}
+
+#[test]
+pub fn guest_modal_enter_confirms_selection() {
+ use crate::tab::AdjustedInput;
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+
+ pane.set_guest_modal(&[client_id]);
+ let outcome = press_key(&mut pane, BareKey::Enter, client_id);
+ assert!(matches!(outcome, Some(AdjustedInput::GuestModalZoom)));
+
+ pane.set_guest_modal(&[client_id]);
+ press_key(&mut pane, BareKey::Down, client_id);
+ let outcome = press_key(&mut pane, BareKey::Enter, client_id);
+ assert!(matches!(outcome, Some(AdjustedInput::GuestModalDescend)));
+}
+
+#[test]
+pub fn guest_modal_digit_shortcuts() {
+ use crate::tab::AdjustedInput;
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+
+ pane.set_guest_modal(&[client_id]);
+ let outcome = press_key(&mut pane, BareKey::Char('1'), client_id);
+ assert!(matches!(outcome, Some(AdjustedInput::GuestModalZoom)));
+
+ pane.set_guest_modal(&[client_id]);
+ let outcome = press_key(&mut pane, BareKey::Char('2'), client_id);
+ assert!(matches!(outcome, Some(AdjustedInput::GuestModalDescend)));
+}
+
+#[test]
+pub fn guest_modal_esc_descends() {
+ use crate::tab::AdjustedInput;
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+ pane.set_guest_modal(&[client_id]);
+ let outcome = press_key(&mut pane, BareKey::Esc, client_id);
+ assert!(matches!(outcome, Some(AdjustedInput::GuestModalDescend)));
+}
+
+#[test]
+pub fn guest_modal_swallows_other_input() {
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+ pane.set_guest_modal(&[client_id]);
+ let outcome = press_key(&mut pane, BareKey::Char('x'), client_id);
+ assert!(outcome.is_none());
+ assert_eq!(pane.guest_modal_selection(client_id), Some(0));
+}
+
+#[test]
+pub fn guest_modal_is_per_client() {
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_a = 1;
+ let client_b = 2;
+ pane.set_guest_modal(&[client_a, client_b]);
+ press_key(&mut pane, BareKey::Down, client_a);
+ assert_eq!(pane.guest_modal_selection(client_a), Some(1));
+ assert_eq!(pane.guest_modal_selection(client_b), Some(0));
+}
+
+#[test]
+pub fn guest_modal_no_modal_passes_input_through() {
+ use zellij_utils::data::BareKey;
+ let mut pane = create_guest_modal_pane();
+ let client_id = 1;
+ let outcome = press_key(&mut pane, BareKey::Char('x'), client_id);
+ assert!(matches!(
+ outcome,
+ Some(crate::tab::AdjustedInput::WriteBytesToTerminal(_))
+ ));
+}
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index d850145c..318b2acc 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -337,6 +337,8 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
PluginCommand::PageScrollUp => page_scroll_up(env),
PluginCommand::PageScrollDown => page_scroll_down(env),
PluginCommand::ToggleFocusFullscreen => toggle_focus_fullscreen(env),
+ PluginCommand::ToggleFocusNoUiFullscreen => toggle_focus_no_ui_fullscreen(env),
+ PluginCommand::FocusHostSession => focus_host_session(env),
PluginCommand::TogglePaneFrames => toggle_pane_frames(env),
PluginCommand::SetPaneFrameStyle(pane_frame_style) => {
set_pane_frame_style(env, pane_frame_style)
@@ -3158,6 +3160,24 @@ fn toggle_focus_fullscreen(env: &PluginEnv) {
apply_action!(action, error_msg, env);
}
+fn toggle_focus_no_ui_fullscreen(env: &PluginEnv) {
+ let error_msg = || {
+ format!(
+ "failed to toggle no-ui full screen in plugin {}",
+ env.name()
+ )
+ };
+ let action = Action::ToggleFocusNoUiFullscreen;
+ apply_action!(action, error_msg, env);
+}
+
+fn focus_host_session(env: &PluginEnv) {
+ env.senders
+ .send_to_screen(ScreenInstruction::FocusHostSession(env.client_id, None))
+ .with_context(|| format!("failed to focus host session from plugin {}", env.name()))
+ .non_fatal();
+}
+
fn toggle_pane_frames(env: &PluginEnv) {
let error_msg = || format!("failed to toggle full screen in plugin {}", env.name());
let action = Action::TogglePaneFrames;
@@ -5535,6 +5555,8 @@ fn check_command_permission(
| PluginCommand::PageScrollDown
| PluginCommand::PageScrollDownInPaneId(..)
| PluginCommand::ToggleFocusFullscreen
+ | PluginCommand::ToggleFocusNoUiFullscreen
+ | PluginCommand::FocusHostSession
| PluginCommand::TogglePaneIdFullscreen(..)
| PluginCommand::TogglePaneFrames
| PluginCommand::SetPaneFrameStyle(..)
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index 08200abb..dfa33b09 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -611,6 +611,14 @@ pub(crate) fn route_action(
))
.with_context(err_context)?;
},
+ Action::ToggleFocusNoUiFullscreen => {
+ senders
+ .send_to_screen(ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(
+ client_id,
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
Action::TogglePaneFrames => {
senders
.send_to_screen(ScreenInstruction::TogglePaneFrames(Some(
@@ -1579,6 +1587,30 @@ pub(crate) fn route_action(
))
.with_context(err_context)?;
},
+ Action::FocusHostSession => {
+ senders
+ .send_to_screen(ScreenInstruction::FocusHostSession(
+ client_id,
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
+ Action::FocusGuestSession => {
+ senders
+ .send_to_screen(ScreenInstruction::FocusGuestSession(
+ client_id,
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
+ Action::ToggleHostFullscreen => {
+ senders
+ .send_to_screen(ScreenInstruction::ToggleHostFullscreen(
+ client_id,
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
Action::RenameSession { name } => {
senders
.send_to_screen(ScreenInstruction::RenameSession(
@@ -2003,6 +2035,14 @@ pub(crate) fn route_action(
))
.with_context(err_context)?;
},
+ Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => {
+ senders
+ .send_to_screen(ScreenInstruction::ToggleNoUiFullscreenWithPaneId(
+ pane_id.into(),
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
Action::TogglePaneEmbedOrFloatingByPaneId { pane_id } => {
senders
.send_to_screen(ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(
@@ -2274,6 +2314,21 @@ pub(crate) fn route_thread_main(
// see the doc comment on `route_action` for why this matters.
let dispatch_inputs =
session_data.read().unwrap().as_ref().and_then(|s| {
+ let in_passthrough =
+ s.key_passthrough_clients.contains_key(&client_id);
+ if in_passthrough {
+ return Some((
+ s.senders.clone(),
+ s.default_shell.clone(),
+ s.session_configuration
+ .get_client_default_input_mode(&client_id),
+ vec![Action::Write {
+ key_with_modifier: Some(key),
+ bytes: raw_bytes,
+ is_kitty_keyboard_protocol,
+ }],
+ ));
+ }
let (kb, im, dim) =
s.get_client_keybinds_and_mode(&client_id)?;
let actions: Vec<Action> = kb
@@ -2703,6 +2758,67 @@ pub(crate) fn route_thread_main(
)]));
}
},
+ ClientToServerMsg::NestedSessionFrameFromHost { ref payload_bytes } => {
+ match zellij_utils::nested_session::decode_payload(payload_bytes) {
+ Some(message @ zellij_utils::nested_session::NestedSessionMessage::AnnounceAck { .. }) => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message },
+ instruction,
+ retry_queue
+ );
+ },
+ Some(message @ zellij_utils::nested_session::NestedSessionMessage::FocusGained { .. }) => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message },
+ instruction,
+ retry_queue
+ );
+ },
+ Some(message @ zellij_utils::nested_session::NestedSessionMessage::FocusLost) => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message },
+ instruction,
+ retry_queue
+ );
+ },
+ Some(message @ zellij_utils::nested_session::NestedSessionMessage::ShortcutUpdate { .. }) => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message },
+ instruction,
+ retry_queue
+ );
+ },
+ Some(message @ zellij_utils::nested_session::NestedSessionMessage::FullscreenState { .. }) => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message },
+ instruction,
+ retry_queue
+ );
+ },
+ Some(message @ zellij_utils::nested_session::NestedSessionMessage::AncestryUpdate { .. }) => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::NestedSessionMessageFromHost { client_id, message },
+ instruction,
+ retry_queue
+ );
+ },
+ Some(message) => {
+ log::debug!(
+ "dropping unsupported nested session frame relayed from host: {:?}",
+ message
+ );
+ },
+ None => {
+ log::debug!("dropping undecodable nested session frame relayed from host");
+ },
+ }
+ },
}
Ok(should_break)
};
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index b72e5a3b..ddf50a98 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -44,17 +44,20 @@ use zellij_utils::data::{
HostTerminalThemeMode, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse,
ListTabsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest,
PaneRenderReport, PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize,
- ResizeStrategy, SessionInfo, Styling, TabInfo, WebSharing,
+ 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::Keybinds;
+use zellij_utils::input::keybinds::{shortcut_for_action, Keybinds};
use zellij_utils::input::mouse::{MouseEvent, MouseEventType};
-use zellij_utils::input::options::{Clipboard, MobileLayoutConfiguration, PaneFrameStyle};
+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;
+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,
@@ -70,18 +73,20 @@ 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::Output,
panes::sixel::SixelImageStore,
panes::PaneId,
plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction, PluginRenderAsset},
pty::{get_default_shell, ClientTabIndexOrPaneId, PtyInstruction, VteBytes},
pty_writer::PtyWriteInstruction,
- tab::{SuppressedPanes, Tab},
+ tab::{GuestChoiceIndicator, SuppressedPanes, Tab},
thread_bus::Bus,
ui::loading_indication::LoadingIndication,
ClientId, ServerInstruction,
@@ -91,6 +96,7 @@ use zellij_utils::{
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};
@@ -429,6 +435,7 @@ pub enum ScreenInstruction {
ClearScroll(ClientId),
CloseFocusedPane(ClientId, Option<NotificationEnd>),
ToggleActiveTerminalFullscreen(ClientId, Option<NotificationEnd>),
+ ToggleActiveTerminalNoUiFullscreen(ClientId, Option<NotificationEnd>),
TogglePaneFrames(Option<NotificationEnd>),
SetPaneFrameStyle(PaneFrameStyle, Option<NotificationEnd>),
SetSelectable(PaneId, bool),
@@ -517,6 +524,22 @@ pub enum ScreenInstruction {
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.
@@ -756,6 +779,7 @@ pub enum ScreenInstruction {
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),
@@ -876,6 +900,7 @@ pub enum ScreenInstruction {
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>),
@@ -903,6 +928,9 @@ pub enum ScreenInstruction {
on: bool,
},
SetShadowFocus(ClientId, PaneId),
+ FocusHostSession(ClientId, Option<NotificationEnd>),
+ FocusGuestSession(ClientId, Option<NotificationEnd>),
+ ToggleHostFullscreen(ClientId, Option<NotificationEnd>),
}
impl From<&ScreenInstruction> for ScreenContext {
@@ -992,6 +1020,9 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::ToggleActiveTerminalFullscreen(..) => {
ScreenContext::ToggleActiveTerminalFullscreen
},
+ ScreenInstruction::ToggleActiveTerminalNoUiFullscreen(..) => {
+ ScreenContext::ToggleActiveTerminalNoUiFullscreen
+ },
ScreenInstruction::TogglePaneFrames(..) => ScreenContext::TogglePaneFrames,
ScreenInstruction::SetPaneFrameStyle(..) => ScreenContext::SetPaneFrameStyle,
ScreenInstruction::SetSelectable(..) => ScreenContext::SetSelectable,
@@ -1028,6 +1059,14 @@ impl From<&ScreenInstruction> for ScreenContext {
},
ScreenInstruction::TerminalColorRegisters(..) => ScreenContext::TerminalColorRegisters,
ScreenInstruction::ForwardHostQuery { .. } => ScreenContext::ForwardHostQuery,
+ ScreenInstruction::NestedSessionMessageFromPane { .. } => {
+ ScreenContext::NestedSessionMessageFromPane
+ },
+ ScreenInstruction::NestedGuestPingTick { .. } => ScreenContext::NestedGuestPingTick,
+ ScreenInstruction::NestedSessionMessageFromHost { .. } => {
+ ScreenContext::NestedSessionMessageFromHost
+ },
+ ScreenInstruction::GuestModalChoice { .. } => ScreenContext::GuestModalChoice,
ScreenInstruction::ForwardedReplyFromHost { .. } => {
ScreenContext::ForwardedReplyFromHost
},
@@ -1243,6 +1282,9 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::ToggleFullscreenWithPaneId(..) => {
ScreenContext::ToggleFullscreenWithPaneId
},
+ ScreenInstruction::ToggleNoUiFullscreenWithPaneId(..) => {
+ ScreenContext::ToggleNoUiFullscreenWithPaneId
+ },
ScreenInstruction::TogglePaneEmbedOrFloatingWithPaneId(..) => {
ScreenContext::TogglePaneEmbedOrFloatingWithPaneId
},
@@ -1275,6 +1317,9 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::ReevaluateMobileMode { .. } => ScreenContext::ReevaluateMobileMode,
ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard,
ScreenInstruction::SetShadowFocus(..) => ScreenContext::SetShadowFocus,
+ ScreenInstruction::FocusHostSession(..) => ScreenContext::FocusHostSession,
+ ScreenInstruction::FocusGuestSession(..) => ScreenContext::FocusGuestSession,
+ ScreenInstruction::ToggleHostFullscreen(..) => ScreenContext::ToggleHostFullscreen,
}
}
}
@@ -1402,6 +1447,29 @@ struct PaneRenderSubscription {
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 {
@@ -1481,9 +1549,22 @@ pub(crate) struct Screen {
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,
}
@@ -1569,6 +1650,7 @@ impl Screen {
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());
@@ -1642,10 +1724,23 @@ impl Screen {
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(),
}
}
@@ -2081,6 +2176,19 @@ impl Screen {
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,
@@ -2154,6 +2262,12 @@ impl Screen {
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(),
@@ -2549,39 +2663,13 @@ impl Screen {
token
}
- /// Synthesise the DSR 997 reply to a `CSI ? 996 n` query from
- /// `host_terminal_theme_mode` and write it directly to the
- /// originating pane's pty. Plugin panes are skipped (they receive
- /// `Event::HostTerminalThemeChanged` and have no notion of
- /// VT-protocol queries). When Zellij has not yet learned the host
- /// mode, the pane receives no reply — matching what a host that
- /// does not implement CSI 2031 would do. The Contour spec only
- /// defines `;1` (dark) and `;2` (light); fabricating any other
- /// code (e.g. `;0`) would be non-conformant.
fn answer_color_palette_mode_query_locally(&mut self, pane_id: PaneId) {
if matches!(pane_id, PaneId::Plugin(_)) {
return;
}
- let code: u8 = match self.host_terminal_theme_mode {
- Some(HostTerminalThemeMode::Dark) => 1,
- Some(HostTerminalThemeMode::Light) => 2,
- None => {
- log::debug!(
- "CSI ?996n received but host_terminal_theme_mode is unknown; \
- dropping (spec defines only 1=dark / 2=light)"
- );
- // The Contour spec defines only ;1 / ;2 — silence is
- // the conformant behaviour when the host's mode is
- // unknown. But the pane may have been forward-paused
- // on the dispatch; if so we still owe it an unblock
- // cycle so any buffered bytes get replayed. Skip the
- // empty resume when the pane is not paused, matching
- // the "stay silent" guarantee.
- if self.is_any_tab_pane_forward_paused(pane_id) {
- let _ = self.resume_pane_after_forward(pane_id, Vec::new());
- }
- 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
@@ -2590,6 +2678,15 @@ impl Screen {
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
@@ -2687,15 +2784,6 @@ impl Screen {
Ok(())
}
- /// Whether any tab owns `pane_id` AND the pane is currently
- /// forward-paused. Used by the `ColorPaletteMode` short-circuit
- /// to skip the empty-payload resume when no pane needs unblocking.
- fn is_any_tab_pane_forward_paused(&self, pane_id: PaneId) -> bool {
- self.tabs
- .values()
- .any(|tab| tab.is_pane_forward_paused(pane_id))
- }
-
/// 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
@@ -2743,6 +2831,983 @@ impl Screen {
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
@@ -2796,14 +3861,9 @@ impl Screen {
},
// Should not reach here: ColorPaletteMode short-circuits in
// `forward_host_query` before any cache-fallback path runs.
- // The Contour spec only defines `;1` and `;2`; if the host
- // mode is unknown there is no compliant reply, so return
- // empty bytes (the existing convention for "no synthesis
- // possible").
- HostQuery::ColorPaletteMode => match self.host_terminal_theme_mode {
- Some(HostTerminalThemeMode::Dark) => b"\x1b[?997;1n".to_vec(),
- Some(HostTerminalThemeMode::Light) => b"\x1b[?997;2n".to_vec(),
- None => Vec::new(),
+ 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(),
},
}
}
@@ -2815,6 +3875,8 @@ impl Screen {
// 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
@@ -3363,6 +4425,9 @@ impl Screen {
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);
+ }
self.tabs.insert(tab_id, tab);
Ok(())
}
@@ -3404,6 +4469,25 @@ impl Screen {
};
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 {
@@ -3489,6 +4573,13 @@ impl Screen {
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)
@@ -3535,12 +4626,39 @@ impl Screen {
.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
@@ -3604,8 +4722,16 @@ impl Screen {
self.close_tab_by_id(tab_id).with_context(err_context)?;
}
}
- self.log_and_report_session_state()
- .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<()> {
@@ -4404,8 +5530,13 @@ impl Screen {
.move_focus_left(client_id)
.and_then(|success| {
if !success {
- self.switch_tab_prev(Some(Direction::Left), true, client_id)
- .context("failed to move focus to previous tab")
+ 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(())
}
@@ -4440,8 +5571,13 @@ impl Screen {
.move_focus_right(client_id)
.and_then(|success| {
if !success {
- self.switch_tab_next(Some(Direction::Right), true, client_id)
- .context("failed to move focus to next tab")
+ 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(())
}
@@ -5052,6 +6188,7 @@ impl Screen {
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;
@@ -5078,6 +6215,7 @@ impl Screen {
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
@@ -5137,6 +6275,7 @@ impl Screen {
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).
@@ -5442,6 +6581,11 @@ impl Screen {
&& 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;
}
@@ -6241,6 +7385,7 @@ pub(crate) fn screen_thread_main(
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(
@@ -6286,6 +7431,7 @@ pub(crate) fn screen_thread_main(
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;
@@ -6734,16 +7880,17 @@ pub(crate) fn screen_thread_main(
screen.render(None)?;
screen.log_and_report_session_state()?;
},
- ScreenInstruction::SwitchFocus(
- client_id,
- _completion_tx, // the action ends here, dropping this will release anything
- // waiting for it
- ) => {
+ 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()?;
},
@@ -6755,11 +7902,16 @@ pub(crate) fn screen_thread_main(
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)?;
}
},
@@ -6771,25 +7923,31 @@ pub(crate) fn screen_thread_main(
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, // the action ends here, dropping this will release anything
- // waiting for it
- ) => {
+ 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()?;
},
@@ -6801,12 +7959,26 @@ pub(crate) fn screen_thread_main(
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)?;
@@ -6821,7 +7993,19 @@ pub(crate) fn screen_thread_main(
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)?;
@@ -6836,12 +8020,26 @@ pub(crate) fn screen_thread_main(
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)?;
@@ -6856,12 +8054,26 @@ pub(crate) fn screen_thread_main(
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)?;
@@ -6876,7 +8088,19 @@ pub(crate) fn screen_thread_main(
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)?;
@@ -6891,12 +8115,26 @@ pub(crate) fn screen_thread_main(
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)?;
@@ -7432,11 +8670,16 @@ pub(crate) fn screen_thread_main(
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()?;
},
@@ -7528,6 +8771,8 @@ pub(crate) fn screen_thread_main(
},
}
+ 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
@@ -7538,7 +8783,7 @@ pub(crate) fn screen_thread_main(
.senders
.send_to_pty(PtyInstruction::ClosePane(id, None));
- screen.log_and_report_session_state()?;
+ screen.log_and_report_session_state().non_fatal();
screen.retain_only_existing_panes_in_pane_groups();
},
ScreenInstruction::HoldPane(id, exit_status, run_command) => {
@@ -7599,6 +8844,16 @@ pub(crate) fn screen_thread_main(
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
@@ -7970,6 +9225,22 @@ pub(crate) fn screen_thread_main(
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
@@ -8092,6 +9363,11 @@ pub(crate) fn screen_thread_main(
} 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();
}
@@ -8114,9 +9390,9 @@ pub(crate) fn screen_thread_main(
screen.render(None)?;
},
ScreenInstruction::RemoveClient(client_id) => {
- screen.remove_client(client_id)?;
- screen.log_and_report_session_state()?;
- screen.render(None)?;
+ 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);
@@ -9474,6 +10750,7 @@ pub(crate) fn screen_thread_main(
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;
@@ -9500,6 +10777,7 @@ pub(crate) fn screen_thread_main(
visual_bell,
focus_follows_mouse,
mouse_click_through,
+ nested_session_handling,
client_id,
)
.non_fatal();
@@ -10464,6 +11742,26 @@ pub(crate) fn screen_thread_main(
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;
@@ -10688,6 +11986,28 @@ pub(crate) fn screen_thread_main(
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(())
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 26668f82..98b7054f 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -30,7 +30,7 @@ use zellij_utils::shared::clean_string_from_control_and_linebreak;
use crate::background_jobs::BackgroundJob;
use crate::pane_groups::PaneGroups;
use crate::pty_writer::PtyWriteInstruction;
-use crate::screen::{CopyOptions, ScreenInstruction};
+use crate::screen::{CopyOptions, GuestModalOutcome, ScreenInstruction};
use crate::ui::hint_text::{
held_hint_variants, hover_hint_variants, resize_hint_variants, HintExitStatus,
};
@@ -48,6 +48,7 @@ use crate::{
output::{CharacterChunk, Output, SixelImageChunk},
panes::floating_panes::floating_pane_grid::half_size_middle_geom,
panes::grid::namespace_notification_id,
+ panes::nested_session_modal::GuestModalShortcuts,
panes::sixel::SixelImageStore,
panes::{FloatingPanes, TiledPanes},
panes::{LinkHandler, PaneId, PluginPane, TerminalPane, EMPTY_TERMINAL_CHARACTER},
@@ -74,6 +75,7 @@ use zellij_utils::{
options::PaneFrameStyle,
parse_keys,
},
+ nested_session::NestedSessionMessage,
pane_size::{Dimension, Offset, PaneGeom, Size, SizeInPixels, Viewport},
};
@@ -243,6 +245,7 @@ pub(crate) struct Tab {
web_clients_allowed: bool,
web_sharing: WebSharing,
mouse_hover_pane_id: HashMap<ClientId, PaneId>,
+ dimmed_clients: HashSet<ClientId>,
plugin_hover_pane_id: HashMap<ClientId, PaneId>,
mouse_last_pane_id: HashMap<ClientId, PaneId>,
mouse_help_text_visible: HashMap<ClientId, bool>,
@@ -459,6 +462,40 @@ pub trait Pane {
// plugin panes have no such concept.
vec![]
}
+ fn drain_nested_session_messages(&mut self) -> Vec<NestedSessionMessage> {
+ vec![]
+ }
+ fn is_nested_guest(&self) -> bool {
+ false
+ }
+ fn set_is_nested_guest(&mut self, _is_nested_guest: bool) {}
+ fn set_guest_modal(&mut self, _client_ids: &[ClientId]) {}
+ fn clear_guest_modal(&mut self, _client_id: ClientId) {}
+ fn clear_all_guest_modals(&mut self) {}
+ fn guest_modal_selection(&self, _client_id: ClientId) -> Option<usize> {
+ None
+ }
+ fn has_guest_modal_for_any_client(&self) -> bool {
+ false
+ }
+ fn set_guest_choice_indicator(
+ &mut self,
+ _client_id: ClientId,
+ _indicator: Option<GuestChoiceIndicator>,
+ ) {
+ }
+ fn guest_choice_indicator(&self, _client_id: ClientId) -> Option<GuestChoiceIndicator> {
+ None
+ }
+ fn clear_all_guest_choice_indicators(&mut self) {}
+ fn set_guest_session_name(&mut self, _session_name: Option<String>) {}
+ fn guest_session_name(&self) -> Option<String> {
+ None
+ }
+ fn set_guest_modal_shortcuts(&mut self, _shortcuts: GuestModalShortcuts) {}
+ fn guest_modal_shortcuts(&self) -> GuestModalShortcuts {
+ GuestModalShortcuts::default()
+ }
/// Mark this pane as awaiting a host-terminal reply for a forward
/// it just dispatched. While paused, vte byte feeding is buffered
/// so that the eventual host reply lands on the pane's stdin in
@@ -740,6 +777,12 @@ pub trait Pane {
}
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum GuestChoiceIndicator {
+ Descended,
+ Dismissed,
+}
+
#[derive(Clone, Debug)]
pub enum AdjustedInput {
WriteBytesToTerminal(Vec<u8>),
@@ -748,6 +791,9 @@ pub enum AdjustedInput {
CloseThisPane,
DropToShellInThisPane { working_dir: Option<PathBuf> },
WriteKeyToPlugin(KeyWithModifier),
+ GuestModalSelectionChanged,
+ GuestModalZoom,
+ GuestModalDescend,
}
pub fn get_next_terminal_position(
tiled_panes: &TiledPanes,
@@ -839,6 +885,7 @@ impl Tab {
let mode_info = Rc::new(RefCell::new(HashMap::new()));
let reserved_top_rows: Rc<RefCell<HashMap<PaneId, usize>>> =
Rc::new(RefCell::new(HashMap::new()));
+ let fullscreen_covers_ui = Rc::new(RefCell::new(false));
let tiled_panes = TiledPanes::new(
display_area.clone(),
@@ -850,6 +897,7 @@ impl Tab {
stacked_resize.clone(),
stacked_pane_list.clone(),
reserved_top_rows.clone(),
+ fullscreen_covers_ui.clone(),
session_is_mirrored,
pane_frame_style,
default_mode_info.clone(),
@@ -864,6 +912,8 @@ impl Tab {
connected_clients_in_app.clone(),
mode_info.clone(),
character_cell_size.clone(),
+ fullscreen_covers_ui.clone(),
+ pane_frame_style,
session_is_mirrored,
default_mode_info.clone(),
style,
@@ -954,9 +1004,20 @@ impl Tab {
tab_has_pending_bell: false,
tab_bell_flash: false,
tab_bell_ring: false,
+ dimmed_clients: HashSet::new(),
}
}
+ pub fn set_client_dimmed(&mut self, client_id: ClientId, dimmed: bool) {
+ if dimmed {
+ self.dimmed_clients.insert(client_id);
+ } else {
+ self.dimmed_clients.remove(&client_id);
+ }
+ self.tiled_panes.set_client_dimmed(client_id, dimmed);
+ self.floating_panes.set_client_dimmed(client_id, dimmed);
+ }
+
pub fn stacked_pane_list_is_active(&self) -> bool {
*self.stacked_pane_list.borrow()
}
@@ -1587,6 +1648,7 @@ impl Tab {
false,
false,
self.mouse_scroll_resize,
+ self.dimmed_clients.clone(),
);
pane_contents_and_ui.set_frame_geom_override(Some(header_geom));
pane_contents_and_ui.set_stack_list_entry(
@@ -2179,6 +2241,7 @@ impl Tab {
self.mouse_help_text_visible.remove(&client_id);
self.mouse_last_pane_id.remove(&client_id);
self.last_mouse_activity_time.remove(&client_id);
+ self.set_client_dimmed(client_id, false);
self.set_force_render();
}
pub fn drain_connected_clients(
@@ -3693,24 +3756,6 @@ impl Tab {
.values()
.any(|s_p| s_p.1.pid() == PaneId::Terminal(pid))
}
- /// Whether the pane with `pane_id` (if owned by this tab) is
- /// currently waiting for a host-forward reply. Used by the
- /// `ColorPaletteMode` short-circuit to decide whether an empty
- /// "host mode unknown" answer needs to drive an unblock cycle:
- /// if the pane is not paused, no work is owed.
- pub fn is_pane_forward_paused(&self, pane_id: PaneId) -> bool {
- self.tiled_panes
- .get_pane(pane_id)
- .or_else(|| self.floating_panes.get_pane(pane_id))
- .or_else(|| {
- self.suppressed_panes
- .values()
- .find(|s_p| s_p.1.pid() == pane_id)
- .map(|s_p| &s_p.1)
- })
- .map(|p| p.is_forward_paused())
- .unwrap_or(false)
- }
pub fn has_plugin(&self, plugin_id: u32) -> bool {
self.tiled_panes.panes_contain(&PaneId::Plugin(plugin_id))
|| self
@@ -3748,6 +3793,189 @@ impl Tab {
pub fn has_non_suppressed_pane_with_pid(&self, pid: &PaneId) -> bool {
self.tiled_panes.panes_contain(pid) || self.floating_panes.panes_contain(pid)
}
+ pub fn set_pane_is_nested_guest(&mut self, pane_id: PaneId, is_nested_guest: bool) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.set_is_nested_guest(is_nested_guest);
+ }
+ }
+ pub fn is_pane_nested_guest(&self, pane_id: PaneId) -> bool {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane(pane_id)
+ .or_else(|| self.floating_panes.get_pane(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &s_p.1)
+ })
+ {
+ pane.is_nested_guest()
+ } else {
+ false
+ }
+ }
+ pub fn nested_guest_pane_ids(&self) -> Vec<PaneId> {
+ self.get_all_pane_ids()
+ .into_iter()
+ .filter(|pane_id| self.is_pane_nested_guest(*pane_id))
+ .collect()
+ }
+ pub fn set_guest_modal_on_pane(&mut self, pane_id: PaneId, client_ids: &[ClientId]) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.set_guest_modal(client_ids);
+ }
+ }
+ pub fn clear_guest_modal_on_pane(&mut self, pane_id: PaneId, client_id: ClientId) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.clear_guest_modal(client_id);
+ }
+ }
+ pub fn clear_all_guest_modals_on_pane(&mut self, pane_id: PaneId) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.clear_all_guest_modals();
+ }
+ }
+ pub fn set_guest_choice_indicator_on_pane(
+ &mut self,
+ pane_id: PaneId,
+ client_id: ClientId,
+ indicator: Option<GuestChoiceIndicator>,
+ ) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.set_guest_choice_indicator(client_id, indicator);
+ }
+ }
+ pub fn clear_all_guest_choice_indicators_on_pane(&mut self, pane_id: PaneId) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.clear_all_guest_choice_indicators();
+ }
+ }
+ pub fn pane_has_guest_modal_for_client(&self, pane_id: PaneId, client_id: ClientId) -> bool {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane(pane_id)
+ .or_else(|| self.floating_panes.get_pane(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &s_p.1)
+ })
+ {
+ pane.guest_modal_selection(client_id).is_some()
+ } else {
+ false
+ }
+ }
+ pub fn clear_guest_modal_for_client_on_all_panes(&mut self, client_id: ClientId) {
+ for pane_id in self.get_all_pane_ids() {
+ self.clear_guest_modal_on_pane(pane_id, client_id);
+ }
+ }
+ pub fn clear_guest_choice_indicator_for_client_on_all_panes(&mut self, client_id: ClientId) {
+ for pane_id in self.get_all_pane_ids() {
+ self.set_guest_choice_indicator_on_pane(pane_id, client_id, None);
+ }
+ }
+ pub fn set_guest_session_name_on_pane(
+ &mut self,
+ pane_id: PaneId,
+ session_name: Option<String>,
+ ) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.set_guest_session_name(session_name);
+ }
+ }
+ pub fn set_guest_modal_shortcuts_on_pane(
+ &mut self,
+ pane_id: PaneId,
+ shortcuts: GuestModalShortcuts,
+ ) {
+ if let Some(pane) = self
+ .tiled_panes
+ .get_pane_mut(pane_id)
+ .or_else(|| self.floating_panes.get_pane_mut(pane_id))
+ .or_else(|| {
+ self.suppressed_panes
+ .values_mut()
+ .find(|s_p| s_p.1.pid() == pane_id)
+ .map(|s_p| &mut s_p.1)
+ })
+ {
+ pane.set_guest_modal_shortcuts(shortcuts);
+ }
+ }
pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> bool {
if self.tiled_panes.panes_contain(&pane_id) {
self.tiled_panes.set_shadow_focus(client_id, pane_id);
@@ -3924,6 +4152,7 @@ impl Tab {
if !forwarded_queries.is_empty() {
terminal_output.arm_forward_pause();
}
+ let nested_session_messages = terminal_output.drain_nested_session_messages();
let clipboard_update = terminal_output.drain_clipboard_update();
let desktop_notifications = terminal_output.drain_desktop_notifications();
let osc7_cwd = terminal_output.drain_osc7_cwd();
@@ -3939,6 +4168,14 @@ impl Tab {
query,
});
}
+ for message in nested_session_messages {
+ let _ =
+ self.senders
+ .send_to_screen(ScreenInstruction::NestedSessionMessageFromPane {
+ pane_id: PaneId::Terminal(pid),
+ message,
+ });
+ }
if let Some(string) = clipboard_update {
self.write_selection_to_clipboard(&string)
.with_context(err_context)?;
@@ -4172,6 +4409,31 @@ impl Tab {
.with_context(err_context)?;
should_update_ui = true;
},
+ Some(AdjustedInput::GuestModalSelectionChanged) => {
+ should_update_ui = true;
+ },
+ Some(AdjustedInput::GuestModalZoom) => {
+ if let Some(client_id) = client_id {
+ let _ =
+ self.senders
+ .send_to_screen(ScreenInstruction::GuestModalChoice {
+ client_id,
+ pane_id: PaneId::Terminal(active_terminal_id),
+ outcome: GuestModalOutcome::Zoom,
+ });
+ }
+ },
+ Some(AdjustedInput::GuestModalDescend) => {
+ if let Some(client_id) = client_id {
+ let _ =
+ self.senders
+ .send_to_screen(ScreenInstruction::GuestModalChoice {
+ client_id,
+ pane_id: PaneId::Terminal(active_terminal_id),
+ outcome: GuestModalOutcome::Descend,
+ });
+ }
+ },
Some(_) => {},
None => {},
}
@@ -4289,6 +4551,14 @@ impl Tab {
)
}
pub fn toggle_active_pane_fullscreen(&mut self, client_id: ClientId) {
+ if self.floating_panes.fullscreen_is_active()
+ || (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes())
+ {
+ self.floating_panes.toggle_active_pane_fullscreen(client_id);
+ self.set_force_render();
+ self.set_should_clear_display_before_rendering();
+ return;
+ }
if self.floating_panes.panes_are_visible() {
return;
}
@@ -4296,6 +4566,12 @@ impl Tab {
self.tiled_panes.toggle_active_pane_fullscreen(client_id);
}
pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) {
+ if self.floating_panes.panes_contain(&pane_id) {
+ self.floating_panes.toggle_pane_fullscreen(pane_id);
+ self.set_force_render();
+ self.set_should_clear_display_before_rendering();
+ return;
+ }
if self.pane_is_hidden_stack_list_member(&pane_id) {
self.make_hidden_stack_list_member_visible(pane_id);
}
@@ -4306,11 +4582,50 @@ impl Tab {
log::error!("No tiled pane with id: {:?} found", pane_id);
}
}
+ pub fn toggle_active_pane_no_ui_fullscreen(&mut self, client_id: ClientId) {
+ if self.floating_panes.fullscreen_is_active()
+ || (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes())
+ {
+ self.floating_panes
+ .toggle_active_pane_no_ui_fullscreen(client_id);
+ self.set_force_render();
+ self.set_should_clear_display_before_rendering();
+ return;
+ }
+ if self.floating_panes.panes_are_visible() {
+ return;
+ }
+ self.dissolve_stack_lists_for_classic_mutation();
+ self.tiled_panes
+ .toggle_active_pane_no_ui_fullscreen(client_id);
+ }
+ pub fn toggle_pane_no_ui_fullscreen(&mut self, pane_id: PaneId) {
+ if self.floating_panes.panes_contain(&pane_id) {
+ self.floating_panes.toggle_pane_no_ui_fullscreen(pane_id);
+ self.set_force_render();
+ self.set_should_clear_display_before_rendering();
+ return;
+ }
+ if self.pane_is_hidden_stack_list_member(&pane_id) {
+ self.make_hidden_stack_list_member_visible(pane_id);
+ }
+ self.dissolve_stack_lists_for_classic_mutation();
+ if self.tiled_panes.panes_contain(&pane_id) {
+ self.tiled_panes.toggle_pane_no_ui_fullscreen(pane_id);
+ } else {
+ log::error!("No tiled pane with id: {:?} found", pane_id);
+ }
+ }
pub fn is_fullscreen_active(&self) -> bool {
- self.tiled_panes.fullscreen_is_active()
+ self.tiled_panes.fullscreen_is_active() || self.floating_panes.fullscreen_is_active()
+ }
+ pub fn fullscreen_covers_ui(&self) -> bool {
+ self.tiled_panes.fullscreen_covers_ui() || self.floating_panes.fullscreen_covers_ui()
}
pub fn fullscreen_pane_id(&self) -> Option<PaneId> {
- self.tiled_panes.fullscreen_pane_id()
+ self.tiled_panes
+ .fullscreen_pane_id()
+ .or_else(|| self.floating_panes.fullscreen_pane_id())
}
pub fn are_floating_panes_visible(&self) -> bool {
self.floating_panes.panes_are_visible()
@@ -4329,19 +4644,19 @@ impl Tab {
return self.tiled_panes.focus_pane_right_fullscreen(client_id);
}
- pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) {
+ pub fn focus_pane_up_fullscreen(&mut self, client_id: ClientId) -> bool {
if !self.is_fullscreen_active() {
- return;
+ return false;
}
- self.tiled_panes.focus_pane_up_fullscreen(client_id);
+ return self.tiled_panes.focus_pane_up_fullscreen(client_id);
}
- pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) {
+ pub fn focus_pane_down_fullscreen(&mut self, client_id: ClientId) -> bool {
if !self.is_fullscreen_active() {
- return;
+ return false;
}
- self.tiled_panes.focus_pane_down_fullscreen(client_id);
+ return self.tiled_panes.focus_pane_down_fullscreen(client_id);
}
pub fn switch_next_pane_fullscreen(&mut self, client_id: ClientId) {
if !self.is_fullscreen_active() {
@@ -4453,8 +4768,10 @@ impl Tab {
.with_context(err_context)?;
self.render_stack_list_headers(output, client_id_override)
.with_context(err_context)?;
- if (self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes())
- || self.floating_panes.has_pinned_panes()
+ let no_ui_fullscreen_active = self.tiled_panes.fullscreen_covers_ui();
+ if !no_ui_fullscreen_active
+ && ((self.floating_panes.panes_are_visible() && self.floating_panes.has_active_panes())
+ || self.floating_panes.has_pinned_panes())
{
self.floating_panes
.render(
@@ -4652,9 +4969,15 @@ impl Tab {
// panes retain stale geometry from before the resize and the layout
// solver fails when fullscreen is later toggled off.
let fullscreen_pane_to_restore = self.tiled_panes.fullscreen_pane_id();
+ let fullscreen_covered_ui = self.tiled_panes.fullscreen_covers_ui();
if fullscreen_pane_to_restore.is_some() {
self.tiled_panes.unset_fullscreen();
}
+ let floating_fullscreen_pane_to_restore = self.floating_panes.fullscreen_pane_id();
+ let floating_fullscreen_covered_ui = self.floating_panes.fullscreen_covers_ui();
+ if floating_fullscreen_pane_to_restore.is_some() {
+ self.floating_panes.unset_fullscreen();
+ }
self.floating_panes.resize(new_screen_size);
// we need to do this explicitly because floating_panes.resize does not do this
self.floating_panes
@@ -4686,7 +5009,20 @@ impl Tab {
);
if let Some(pane_id) = fullscreen_pane_to_restore {
if self.tiled_panes.panes_contain(&pane_id) {
- self.tiled_panes.toggle_pane_fullscreen(pane_id);
+ if fullscreen_covered_ui {
+ self.tiled_panes.toggle_pane_no_ui_fullscreen(pane_id);
+ } else {
+ self.tiled_panes.toggle_pane_fullscreen(pane_id);
+ }
+ }
+ }
+ if let Some(pane_id) = floating_fullscreen_pane_to_restore {
+ if self.floating_panes.panes_contain(&pane_id) {
+ if floating_fullscreen_covered_ui {
+ self.floating_panes.toggle_pane_no_ui_fullscreen(pane_id);
+ } else {
+ self.floating_panes.toggle_pane_fullscreen(pane_id);
+ }
}
}
self.resize_all_stack_list_hidden_members();
@@ -4694,6 +5030,9 @@ impl Tab {
}
pub fn resize(&mut self, client_id: ClientId, strategy: ResizeStrategy) -> Result<()> {
let err_context = || format!("unable to resize pane");
+ if self.floating_panes.fullscreen_is_active() {
+ return Ok(());
+ }
if self.floating_panes.panes_are_visible() {
let successfully_resized = self
.floating_panes
@@ -4779,6 +5118,23 @@ impl Tab {
self.tiled_panes.focus_pane_on_edge(direction, client_id);
}
}
+ pub fn focus_pane_adjacent_to(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ client_id: ClientId,
+ ) -> Option<PaneId> {
+ if self.floating_panes.panes_are_visible() {
+ return self
+ .floating_panes
+ .focus_pane_adjacent_to(pane_id, direction, client_id);
+ }
+ if self.tiled_panes.fullscreen_is_active() {
+ return None;
+ }
+ self.tiled_panes
+ .focus_pane_adjacent_to(pane_id, direction, client_id)
+ }
// returns a boolean that indicates whether the focus moved
pub fn move_focus_left(&mut self, client_id: ClientId) -> Result<bool> {
let err_context = || format!("failed to move focus left for client {}", client_id);
@@ -4817,8 +5173,7 @@ impl Tab {
return Ok(false);
}
if self.tiled_panes.fullscreen_is_active() {
- self.focus_pane_down_fullscreen(client_id);
- return Ok(true);
+ return Ok(self.focus_pane_down_fullscreen(client_id));
}
if self.stacked_pane_list_is_active()
&& self.move_focus_within_stack_list(client_id, true)
@@ -4844,8 +5199,7 @@ impl Tab {
return Ok(false);
}
if self.tiled_panes.fullscreen_is_active() {
- self.focus_pane_up_fullscreen(client_id);
- return Ok(true);
+ return Ok(self.focus_pane_up_fullscreen(client_id));
}
if self.stacked_pane_list_is_active()
&& self.move_focus_within_stack_list(client_id, false)
@@ -5116,7 +5470,7 @@ impl Tab {
if let Some(pane) = self.floating_panes.get_pane(pane_id) {
let mut info = pane_info_for_pane(&pane_id, pane, &current_pane_group);
info.is_focused = false;
- info.is_fullscreen = false;
+ info.is_fullscreen = self.floating_panes.fullscreen_pane_id() == Some(pane_id);
info.is_floating = true;
info.is_suppressed = false;
return Some(info);
@@ -5205,6 +5559,9 @@ impl Tab {
};
}
let closed_pane = if self.floating_panes.panes_contain(&id) {
+ if self.floating_panes.fullscreen_pane_id() == Some(id) {
+ self.floating_panes.unset_fullscreen();
+ }
let closed_pane = self.floating_panes.remove_pane(id);
self.floating_panes.move_clients_out_of_pane(id);
if !self.floating_panes.has_selectable_panes() {
@@ -5291,6 +5648,9 @@ impl Tab {
};
}
if self.floating_panes.panes_contain(&id) {
+ if self.floating_panes.fullscreen_pane_id() == Some(id) {
+ self.floating_panes.unset_fullscreen();
+ }
let mut closed_pane = self.floating_panes.remove_pane(id);
self.floating_panes.move_clients_out_of_pane(id);
if !self.floating_panes.has_panes() {
@@ -5911,19 +6271,12 @@ impl Tab {
let err_context = || format!("failed to get id of pane at position {point:?}");
if self.tiled_panes.fullscreen_is_active()
- && self
- .is_position_inside_viewport(point)
- .with_context(err_context)?
+ && (self.tiled_panes.fullscreen_covers_ui()
+ || self
+ .is_position_inside_viewport(point)
+ .with_context(err_context)?)
{
- // TODO: instead of doing this, record the pane that is in fullscreen
- let first_client_id = self
- .connected_clients
- .borrow()
- .iter()
- .copied()
- .next()
- .with_context(err_context)?;
- return Ok(self.tiled_panes.get_active_pane_id(first_client_id));
+ return Ok(self.tiled_panes.fullscreen_pane_id());
}
let (stacked_pane_ids_under_flexible_pane, _stacked_pane_ids_over_flexible_pane) = {
@@ -6216,6 +6569,7 @@ impl Tab {
pub fn set_pane_frames(&mut self, pane_frame_style: PaneFrameStyle) {
self.tiled_panes.set_pane_frames(pane_frame_style);
+ self.floating_panes.set_pane_frame_style(pane_frame_style);
self.pane_frame_style = pane_frame_style;
self.set_should_clear_display_before_rendering();
self.set_force_render();
@@ -6446,6 +6800,9 @@ impl Tab {
pub fn hide_floating_panes(&mut self) {
// this function is to be preferred to directly invoking
// floating_panes.toggle_show_panes(false)
+ if self.floating_panes.fullscreen_is_active() {
+ self.floating_panes.unset_fullscreen();
+ }
self.floating_panes.toggle_show_panes(false);
self.tiled_panes.focus_all_panes();
self.set_force_render();
@@ -6725,6 +7082,9 @@ impl Tab {
client_id: Option<ClientId>,
) -> Result<()> {
let err_context = || format!("failed to add floating pane");
+ if self.floating_panes.fullscreen_is_active() {
+ self.floating_panes.unset_fullscreen();
+ }
if let Some(mut new_pane_geom) = self.floating_panes.find_room_for_new_pane() {
if let Some(floating_pane_coordinates) = &floating_pane_coordinates {
let viewport = self.viewport.borrow();
@@ -7446,6 +7806,9 @@ impl Tab {
pub fn toggle_fullscreen_by_pane_id(&mut self, pane_id: PaneId) {
self.toggle_pane_fullscreen(pane_id);
}
+ pub fn toggle_no_ui_fullscreen_by_pane_id(&mut self, pane_id: PaneId) {
+ self.toggle_pane_no_ui_fullscreen(pane_id);
+ }
pub fn close_pane_by_pane_id(
&mut self,
pane_id: PaneId,
diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs
index ca2a0804..288e58af 100644
--- a/zellij-server/src/tab/mouse_handler.rs
+++ b/zellij-server/src/tab/mouse_handler.rs
@@ -8,6 +8,7 @@ use zellij_utils::position::Position;
use crate::background_jobs::BackgroundJob;
use crate::panes::PaneId;
use crate::plugins::PluginInstruction;
+use crate::screen::{GuestModalOutcome, ScreenInstruction};
use crate::ClientId;
use super::{Pane, Tab};
@@ -349,11 +350,72 @@ impl MouseHandler {
event: &MouseEvent,
client_id: ClientId,
) -> Result<MouseEffect> {
+ if let Some(effect) = Self::intercept_guest_modal_mouse_event(tab, event, client_id)? {
+ return Ok(effect);
+ }
let context = Self::gather_mouse_event_context(tab, event, client_id)?;
let action = Self::determine_mouse_action(event, &context)?;
Self::execute_mouse_action(tab, action, event, client_id)
}
+ fn intercept_guest_modal_mouse_event(
+ tab: &mut Tab,
+ event: &MouseEvent,
+ client_id: ClientId,
+ ) -> Result<Option<MouseEffect>> {
+ let pane_id_at_position = Self::get_pane_at(tab, &event.position, false)?.map(|p| p.pid());
+ let pane_id = match pane_id_at_position {
+ Some(pane_id) => pane_id,
+ None => return Ok(None),
+ };
+ if !tab.pane_has_guest_modal_for_client(pane_id, client_id) {
+ return Ok(None);
+ }
+ let hit_option = if event.event_type == MouseEventType::Release && event.left {
+ let style = tab.style;
+ if let Some(pane) = tab.get_pane_with_id(pane_id) {
+ let relative_position = pane.relative_position(&event.position);
+ let rows = pane.get_content_rows();
+ let columns = pane.get_content_columns();
+ let row = relative_position.line();
+ if row < 0 {
+ None
+ } else {
+ let session_name = pane.guest_session_name().unwrap_or_default();
+ let selection = pane.guest_modal_selection(client_id).unwrap_or(0);
+ let shortcuts = pane.guest_modal_shortcuts();
+ crate::panes::nested_session_modal::guest_modal_option_at_content_row(
+ rows,
+ columns,
+ row as usize,
+ &style,
+ &session_name,
+ selection,
+ &shortcuts,
+ )
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+ if let Some(option) = hit_option {
+ let outcome = match option {
+ 0 => GuestModalOutcome::Zoom,
+ _ => GuestModalOutcome::Descend,
+ };
+ let _ = tab
+ .senders
+ .send_to_screen(ScreenInstruction::GuestModalChoice {
+ client_id,
+ pane_id,
+ outcome,
+ });
+ }
+ Ok(Some(MouseEffect::state_changed()))
+ }
+
fn gather_mouse_event_context(
tab: &mut Tab,
event: &MouseEvent,
diff --git a/zellij-server/src/tab/unit/layout_applier_tests.rs b/zellij-server/src/tab/unit/layout_applier_tests.rs
index f67a9a17..8ebba80d 100644
--- a/zellij-server/src/tab/unit/layout_applier_tests.rs
+++ b/zellij-server/src/tab/unit/layout_applier_tests.rs
@@ -189,6 +189,7 @@ fn create_layout_applier_fixtures(
let stacked_resize = Rc::new(RefCell::new(false));
let stacked_pane_list = Rc::new(RefCell::new(false));
let reserved_top_rows = Rc::new(RefCell::new(HashMap::new()));
+ let fullscreen_covers_ui = Rc::new(RefCell::new(false));
let session_is_mirrored = true;
let draw_pane_frames = PaneFrameStyle::Full;
let default_mode_info = ModeInfo::default();
@@ -203,6 +204,7 @@ fn create_layout_applier_fixtures(
stacked_resize,
stacked_pane_list,
reserved_top_rows,
+ fullscreen_covers_ui.clone(),
session_is_mirrored,
draw_pane_frames,
default_mode_info.clone(),
@@ -219,6 +221,8 @@ fn create_layout_applier_fixtures(
connected_clients.clone(),
mode_info,
character_cell_size.clone(),
+ fullscreen_covers_ui,
+ draw_pane_frames,
session_is_mirrored,
default_mode_info,
style.clone(),
@@ -323,6 +327,7 @@ fn create_layout_applier_fixtures_with_receivers(
let stacked_resize = Rc::new(RefCell::new(false));
let stacked_pane_list = Rc::new(RefCell::new(false));
let reserved_top_rows = Rc::new(RefCell::new(HashMap::new()));
+ let fullscreen_covers_ui = Rc::new(RefCell::new(false));
let session_is_mirrored = true;
let draw_pane_frames = PaneFrameStyle::Full;
let default_mode_info = ModeInfo::default();
@@ -337,6 +342,7 @@ fn create_layout_applier_fixtures_with_receivers(
stacked_resize,
stacked_pane_list,
reserved_top_rows,
+ fullscreen_covers_ui.clone(),
session_is_mirrored,
draw_pane_frames,
default_mode_info.clone(),
@@ -353,6 +359,8 @@ fn create_layout_applier_fixtures_with_receivers(
connected_clients.clone(),
mode_info,
character_cell_size.clone(),
+ fullscreen_covers_ui,
+ draw_pane_frames,
session_is_mirrored,
default_mode_info,
style.clone(),
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap
new file mode 100644
index 00000000..5d2a715d
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__changing_frame_style_while_floating_fullscreen_reflows_content.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C):
+01 (C):
+02 (C):
+03 (C): I am a floating pane
+04 (C):
+05 (C):
+06 (C):
+07 (C):
+08 (C):
+09 (C):
+10 (C):
+11 (C):
+12 (C):
+13 (C):
+14 (C):
+15 (C):
+16 (C):
+17 (C):
+18 (C):
+19 (C):
+
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap
new file mode 100644
index 00000000..9abbfc6b
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__exiting_floating_fullscreen_clears_background.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────────────────────────────────────────────── SCROLL: 0/1 ┐
+01 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+02 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+03 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+04 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+05 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE┌ Pane #2 ──────────────────────────────────────── PIN [ ] ┐EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+06 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+07 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+08 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+09 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ I am a floating pane │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+10 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+11 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+12 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+13 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE│ │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+14 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEE└──────────────────────────────────────────────────────────┘EEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+15 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+16 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+17 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+18 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE│
+19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
+
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap
new file mode 100644
index 00000000..4f0865a3
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_full_frame_style.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #2 ───────────────────────────────────────────────────────────────────────────────────────────────────── PIN [ ] ┐
+01 (C): │ │
+02 (C): │ │
+03 (C): │ │
+04 (C): │ I am a floating pane │
+05 (C): │ │
+06 (C): │ │
+07 (C): │ │
+08 (C): │ │
+09 (C): │ │
+10 (C): │ │
+11 (C): │ │
+12 (C): │ │
+13 (C): │ │
+14 (C): │ │
+15 (C): │ │
+16 (C): │ │
+17 (C): │ │
+18 (C): │ │
+19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
+
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap
new file mode 100644
index 00000000..1ea8e0fa
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_hides_other_floating_and_pinned_panes.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #2 ───────────────────────────────────────────────────────────────────────────────────────────────────── PIN [ ] ┐
+01 (C): │ │
+02 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+03 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+04 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+05 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+06 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+07 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+08 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+09 (C): │EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE │
+10 (C): │ │
+11 (C): │ │
+12 (C): │ │
+13 (C): │ │
+14 (C): │ │
+15 (C): │ │
+16 (C): │ │
+17 (C): │ │
+18 (C): │ │
+19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
+
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap
new file mode 100644
index 00000000..5d2a715d
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_none_frame_style.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C):
+01 (C):
+02 (C):
+03 (C): I am a floating pane
+04 (C):
+05 (C):
+06 (C):
+07 (C):
+08 (C):
+09 (C):
+10 (C):
+11 (C):
+12 (C):
+13 (C):
+14 (C):
+15 (C):
+16 (C):
+17 (C):
+18 (C):
+19 (C):
+
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap
new file mode 100644
index 00000000..c788e3b6
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_fullscreen_titles_frame_style.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): Pane #2
+01 (C):
+02 (C):
+03 (C):
+04 (C): I am a floating pane
+05 (C):
+06 (C):
+07 (C):
+08 (C):
+09 (C):
+10 (C):
+11 (C):
+12 (C):
+13 (C):
+14 (C):
+15 (C):
+16 (C):
+17 (C):
+18 (C):
+19 (C):
+
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap
new file mode 100644
index 00000000..5d2a715d
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__floating_no_ui_fullscreen_covers_tab_and_status_bar.snap
@@ -0,0 +1,25 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C):
+01 (C):
+02 (C):
+03 (C): I am a floating pane
+04 (C):
+05 (C):
+06 (C):
+07 (C):
+08 (C):
+09 (C):
+10 (C):
+11 (C):
+12 (C):
+13 (C):
+14 (C):
+15 (C):
+16 (C):
+17 (C):
+18 (C):
+19 (C):
+
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index e5de0b58..3e778b16 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -3446,6 +3446,228 @@ fn float_embedded_pane_without_pane_frames() {
assert_snapshot!(snapshot);
}
+fn setup_tab_with_focused_floating_pane(
+ size: Size,
+ client_id: ClientId,
+ frame_style: PaneFrameStyle,
+) -> Tab {
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ tab.set_pane_frames(frame_style);
+ tab.toggle_floating_panes(Some(client_id), None, None)
+ .unwrap();
+ tab.new_pane(
+ PaneId::Terminal(2),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ tab.handle_pty_bytes(
+ 2,
+ Vec::from("\n\n\n I am a floating pane".as_bytes()),
+ )
+ .unwrap();
+ tab
+}
+
+#[test]
+fn floating_fullscreen_full_frame_style() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Full);
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn floating_fullscreen_titles_frame_style() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Titles);
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn floating_fullscreen_none_frame_style() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::None);
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn changing_frame_style_while_floating_fullscreen_reflows_content() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Full);
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.set_pane_frames(PaneFrameStyle::None);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn exiting_floating_fullscreen_clears_background() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ tab.handle_pty_bytes(1, Vec::from("\u{1b}#8".as_bytes()))
+ .unwrap();
+ tab.toggle_floating_panes(Some(client_id), None, None)
+ .unwrap();
+ tab.new_pane(
+ PaneId::Terminal(2),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ tab.handle_pty_bytes(
+ 2,
+ Vec::from("\n\n\n I am a floating pane".as_bytes()),
+ )
+ .unwrap();
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn floating_no_ui_fullscreen_covers_tab_and_status_bar() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = setup_tab_with_focused_floating_pane(size, client_id, PaneFrameStyle::Full);
+ let mut output = Output::default();
+ tab.toggle_active_pane_no_ui_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn floating_fullscreen_hides_other_floating_and_pinned_panes() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ tab.toggle_floating_panes(Some(client_id), None, None)
+ .unwrap();
+ tab.new_pane(
+ PaneId::Terminal(2),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ tab.new_pane(
+ PaneId::Terminal(3),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+ tab.set_floating_pane_pinned(PaneId::Terminal(3), true);
+ tab.handle_pty_bytes(2, Vec::from("\u{1b}#8".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(3, Vec::from("\u{1b}#8".as_bytes()))
+ .unwrap();
+ let _ = tab.focus_pane_with_id(PaneId::Terminal(2), false, false, client_id);
+ let mut output = Output::default();
+ tab.toggle_active_pane_fullscreen(client_id);
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
+
#[test]
fn cannot_float_only_embedded_pane() {
let size = Size {
diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs
index e9f8a93e..f0c2d582 100644
--- a/zellij-server/src/tab/unit/tab_tests.rs
+++ b/zellij-server/src/tab/unit/tab_tests.rs
@@ -12,7 +12,7 @@ use zellij_utils::errors::prelude::*;
use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout};
use zellij_utils::input::options::PaneFrameStyle;
use zellij_utils::ipc::IpcReceiverWithContext;
-use zellij_utils::pane_size::{Size, SizeInPixels};
+use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels};
use crate::os_input_output::AsyncReader;
use std::cell::RefCell;
@@ -1232,6 +1232,820 @@ pub fn resize_while_fullscreen_updates_hidden_pane_geometry() {
}
#[test]
+pub fn toggle_no_ui_fullscreen_covers_whole_display_and_restores() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(size, stacked_resize);
+ for i in 2..5 {
+ let new_pane_id = PaneId::Terminal(i);
+ tab.new_pane(
+ new_pane_id,
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+ }
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "NoUi fullscreen is active");
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "NoUi fullscreen covers the UI rows"
+ );
+ let active_pane = tab.tiled_panes.panes.get(&PaneId::Terminal(4)).unwrap();
+ assert_eq!(active_pane.x(), 0, "Pane x is on display edge");
+ assert_eq!(active_pane.y(), 0, "Pane y is on display edge");
+ assert_eq!(active_pane.cols(), 121, "Pane cols match display cols");
+ assert_eq!(active_pane.rows(), 20, "Pane rows match display rows");
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(!tab.is_fullscreen_active(), "NoUi fullscreen toggled off");
+ assert!(
+ !tab.fullscreen_covers_ui(),
+ "NoUi flag cleared after toggle off"
+ );
+ let active_pane = tab.tiled_panes.panes.get(&PaneId::Terminal(4)).unwrap();
+ assert_eq!(active_pane.x(), 61, "Pane x restored");
+ assert_eq!(active_pane.y(), 10, "Pane y restored");
+ assert_eq!(active_pane.cols(), 60, "Pane cols restored");
+ assert_eq!(active_pane.rows(), 10, "Pane rows restored");
+}
+
+#[test]
+pub fn regular_fullscreen_switches_to_no_ui_fullscreen() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(size, stacked_resize);
+ for i in 2..5 {
+ let new_pane_id = PaneId::Terminal(i);
+ tab.new_pane(
+ new_pane_id,
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+ }
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Regular fullscreen is active");
+ assert!(
+ !tab.fullscreen_covers_ui(),
+ "Regular fullscreen leaves the UI rows"
+ );
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ tab.is_fullscreen_active(),
+ "Fullscreen stays active after switching kinds"
+ );
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "Fullscreen switched to covering the UI rows"
+ );
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Fullscreen toggled off entirely"
+ );
+}
+
+#[test]
+pub fn resize_whole_tab_while_no_ui_fullscreen_preserves_no_ui() {
+ let initial_size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(initial_size, stacked_resize);
+ for i in 2..5 {
+ let new_pane_id = PaneId::Terminal(i);
+ tab.new_pane(
+ new_pane_id,
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+ }
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "NoUi fullscreen is active before the resize"
+ );
+
+ let new_size = Size { cols: 80, rows: 30 };
+ tab.resize_whole_tab(new_size).unwrap();
+
+ assert!(
+ tab.is_fullscreen_active(),
+ "Fullscreen is preserved across a host-terminal resize"
+ );
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "NoUi kind is preserved across a host-terminal resize"
+ );
+ let active_pane = tab
+ .tiled_panes
+ .panes
+ .get(&PaneId::Terminal(4))
+ .expect("Active fullscreen pane is still present");
+ assert_eq!(
+ active_pane.cols(),
+ new_size.cols,
+ "NoUi fullscreen pane cols match the new display cols"
+ );
+ assert_eq!(
+ active_pane.rows(),
+ new_size.rows,
+ "NoUi fullscreen pane rows match the new display rows"
+ );
+ assert_eq!(
+ active_pane.x(),
+ 0,
+ "NoUi fullscreen pane x is at display edge"
+ );
+ assert_eq!(
+ active_pane.y(),
+ 0,
+ "NoUi fullscreen pane y is at display edge"
+ );
+}
+
+fn create_tab_with_two_floating_panes() -> Tab {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(size, stacked_resize);
+ tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None)
+ .unwrap();
+ tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None)
+ .unwrap();
+ tab
+}
+
+fn floating_pane_geom(tab: &Tab, pane_id: PaneId) -> PaneGeom {
+ tab.floating_panes
+ .get(&pane_id)
+ .expect("floating pane exists")
+ .current_geom()
+}
+
+#[test]
+pub fn toggle_floating_pane_fullscreen_expands_over_viewport() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let viewport = *tab.viewport.borrow();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+
+ tab.toggle_active_pane_fullscreen(1);
+
+ assert!(tab.is_fullscreen_active(), "Floating fullscreen is active");
+ assert!(
+ !tab.fullscreen_covers_ui(),
+ "Regular floating fullscreen leaves the UI rows"
+ );
+ assert_eq!(
+ tab.fullscreen_pane_id(),
+ Some(active_pane_id),
+ "Fullscreen tracks the active floating pane"
+ );
+ let geom = floating_pane_geom(&tab, active_pane_id);
+ assert_eq!(geom.x, viewport.x, "Fullscreen pane x matches viewport");
+ assert_eq!(geom.y, viewport.y, "Fullscreen pane y matches viewport");
+ assert_eq!(
+ geom.cols.as_usize(),
+ viewport.cols,
+ "Fullscreen pane cols match viewport cols"
+ );
+ assert_eq!(
+ geom.rows.as_usize(),
+ viewport.rows,
+ "Fullscreen pane rows match viewport rows"
+ );
+}
+
+#[test]
+pub fn toggle_floating_pane_no_ui_fullscreen_covers_whole_display() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let display_area = *tab.display_area.borrow();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+
+ assert!(
+ tab.is_fullscreen_active(),
+ "Floating no-ui fullscreen is active"
+ );
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "Floating no-ui fullscreen covers the UI rows"
+ );
+ let geom = floating_pane_geom(&tab, active_pane_id);
+ assert_eq!(geom.x, 0, "No-ui fullscreen pane x is on display edge");
+ assert_eq!(geom.y, 0, "No-ui fullscreen pane y is on display edge");
+ assert_eq!(
+ geom.cols.as_usize(),
+ display_area.cols,
+ "No-ui fullscreen pane cols match display cols"
+ );
+ assert_eq!(
+ geom.rows.as_usize(),
+ display_area.rows,
+ "No-ui fullscreen pane rows match display rows"
+ );
+}
+
+#[test]
+pub fn toggle_floating_pane_fullscreen_off_restores_geometry() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ let geom_before = floating_pane_geom(&tab, active_pane_id);
+
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active after toggle");
+ tab.toggle_active_pane_fullscreen(1);
+
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Fullscreen cleared after toggling off"
+ );
+ assert_eq!(
+ floating_pane_geom(&tab, active_pane_id),
+ geom_before,
+ "Floating pane geometry restored after fullscreen off"
+ );
+}
+
+#[test]
+pub fn regular_floating_fullscreen_switches_to_no_ui() {
+ let mut tab = create_tab_with_two_floating_panes();
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(
+ tab.is_fullscreen_active() && !tab.fullscreen_covers_ui(),
+ "Regular floating fullscreen active"
+ );
+
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ tab.is_fullscreen_active(),
+ "Fullscreen stays active when switching kinds"
+ );
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "Fullscreen switched to covering the UI rows"
+ );
+
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Fullscreen toggled off entirely"
+ );
+}
+
+#[test]
+pub fn floating_fullscreen_on_lone_floating_pane() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(size, stacked_resize);
+ tab.new_floating_pane(PaneId::Terminal(2), None, None, false, true, None, None)
+ .unwrap();
+ let viewport = *tab.viewport.borrow();
+
+ tab.toggle_active_pane_fullscreen(1);
+
+ assert!(
+ tab.is_fullscreen_active(),
+ "A lone floating pane still enters fullscreen"
+ );
+ let geom = floating_pane_geom(&tab, PaneId::Terminal(2));
+ assert_eq!(
+ geom.cols.as_usize(),
+ viewport.cols,
+ "Lone floating fullscreen pane covers the viewport cols"
+ );
+ assert_eq!(
+ geom.rows.as_usize(),
+ viewport.rows,
+ "Lone floating fullscreen pane covers the viewport rows"
+ );
+}
+
+#[test]
+pub fn floating_and_tiled_fullscreen_are_mutually_exclusive() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(size, stacked_resize);
+ tab.new_pane(
+ PaneId::Terminal(2),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+ tab.new_floating_pane(PaneId::Terminal(3), None, None, false, true, None, None)
+ .unwrap();
+
+ tab.toggle_active_pane_fullscreen(1);
+ assert_eq!(
+ tab.fullscreen_pane_id(),
+ Some(PaneId::Terminal(3)),
+ "The focused floating pane is fullscreen"
+ );
+ assert!(
+ !tab.tiled_panes.fullscreen_is_active(),
+ "No tiled fullscreen while a floating pane is fullscreen"
+ );
+
+ tab.hide_floating_panes();
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(
+ tab.tiled_panes.fullscreen_is_active(),
+ "A tiled pane can be fullscreen after floating is hidden"
+ );
+ assert!(
+ !tab.floating_panes.fullscreen_is_active(),
+ "No floating fullscreen while a tiled pane is fullscreen"
+ );
+}
+
+#[test]
+pub fn move_focus_while_floating_fullscreen_transfers_fullscreen() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let first = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+
+ tab.toggle_active_pane_fullscreen(1);
+ assert_eq!(
+ tab.fullscreen_pane_id(),
+ Some(first),
+ "Fullscreen starts on the focused floating pane"
+ );
+
+ let _ = tab.move_focus_left(1);
+
+ assert!(
+ tab.is_fullscreen_active(),
+ "Fullscreen stays active after moving focus"
+ );
+ let new_active = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused after the move");
+ assert_eq!(
+ tab.fullscreen_pane_id(),
+ Some(new_active),
+ "Fullscreen transferred to the newly focused floating pane"
+ );
+}
+
+#[test]
+pub fn resize_while_floating_fullscreen_is_noop() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_fullscreen(1);
+ let geom_before = floating_pane_geom(&tab, active_pane_id);
+
+ tab.resize(1, ResizeStrategy::new(Resize::Increase, None))
+ .unwrap();
+
+ assert_eq!(
+ floating_pane_geom(&tab, active_pane_id),
+ geom_before,
+ "Resizing a floating fullscreen pane does not change its geometry"
+ );
+}
+
+#[test]
+pub fn move_pane_while_floating_fullscreen_is_noop() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_fullscreen(1);
+ let geom_before = floating_pane_geom(&tab, active_pane_id);
+
+ tab.move_active_pane_down(1);
+ tab.move_active_pane_up(1);
+ tab.move_active_pane_left(1);
+ tab.move_active_pane_right(1);
+
+ assert_eq!(
+ floating_pane_geom(&tab, active_pane_id),
+ geom_before,
+ "Moving a floating fullscreen pane does not change its geometry"
+ );
+}
+
+#[test]
+pub fn closing_floating_fullscreen_pane_clears_state() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active before close");
+
+ tab.close_pane(active_pane_id, false, None);
+
+ assert!(
+ !tab.floating_panes.fullscreen_is_active(),
+ "Closing the fullscreen floating pane clears fullscreen state"
+ );
+}
+
+#[test]
+pub fn adding_floating_pane_while_fullscreen_unsets_fullscreen() {
+ let mut tab = create_tab_with_two_floating_panes();
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active before add");
+
+ tab.new_floating_pane(PaneId::Terminal(4), None, None, false, true, None, None)
+ .unwrap();
+
+ assert!(
+ !tab.floating_panes.fullscreen_is_active(),
+ "Adding a floating pane breaks out of floating fullscreen"
+ );
+}
+
+#[test]
+pub fn hiding_floating_layer_while_fullscreen_unsets_fullscreen() {
+ let mut tab = create_tab_with_two_floating_panes();
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active before hide");
+
+ tab.hide_floating_panes();
+
+ assert!(
+ !tab.floating_panes.fullscreen_is_active(),
+ "Hiding the floating layer clears floating fullscreen"
+ );
+}
+
+#[test]
+pub fn embed_floating_fullscreen_pane_unsets_and_embeds() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active before embed");
+
+ tab.toggle_pane_embed_or_floating(1).unwrap();
+
+ assert!(
+ !tab.floating_panes.fullscreen_is_active(),
+ "Embedding clears floating fullscreen state"
+ );
+ assert!(
+ tab.tiled_panes.panes_contain(&active_pane_id),
+ "The embedded pane is now a tiled pane"
+ );
+}
+
+#[test]
+pub fn resize_whole_tab_while_floating_fullscreen_preserves_regular() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_fullscreen(1);
+
+ let new_size = Size { cols: 80, rows: 30 };
+ tab.resize_whole_tab(new_size).unwrap();
+
+ assert!(
+ tab.is_fullscreen_active() && !tab.fullscreen_covers_ui(),
+ "Regular floating fullscreen preserved across resize"
+ );
+ let viewport = *tab.viewport.borrow();
+ let geom = floating_pane_geom(&tab, active_pane_id);
+ assert_eq!(
+ geom.cols.as_usize(),
+ viewport.cols,
+ "Floating fullscreen pane re-expands to the new viewport cols"
+ );
+ assert_eq!(
+ geom.rows.as_usize(),
+ viewport.rows,
+ "Floating fullscreen pane re-expands to the new viewport rows"
+ );
+}
+
+#[test]
+pub fn resize_whole_tab_while_floating_no_ui_fullscreen_preserves_no_ui() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+
+ let new_size = Size { cols: 80, rows: 30 };
+ tab.resize_whole_tab(new_size).unwrap();
+
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "Floating no-ui fullscreen preserved across resize"
+ );
+ let geom = floating_pane_geom(&tab, active_pane_id);
+ assert_eq!(
+ geom.cols.as_usize(),
+ new_size.cols,
+ "Floating no-ui fullscreen pane re-expands to the new display cols"
+ );
+ assert_eq!(
+ geom.rows.as_usize(),
+ new_size.rows,
+ "Floating no-ui fullscreen pane re-expands to the new display rows"
+ );
+}
+
+#[test]
+pub fn pane_info_reports_floating_fullscreen() {
+ let mut tab = create_tab_with_two_floating_panes();
+ let active_pane_id = tab
+ .floating_panes
+ .active_pane_id(1)
+ .expect("a floating pane is focused");
+ tab.toggle_active_pane_fullscreen(1);
+
+ let fullscreen_info = tab
+ .get_pane_info(active_pane_id)
+ .expect("pane info for the fullscreen floating pane");
+ assert!(
+ fullscreen_info.is_fullscreen,
+ "The fullscreen floating pane reports is_fullscreen"
+ );
+ assert!(
+ fullscreen_info.is_floating,
+ "The fullscreen pane still reports is_floating"
+ );
+
+ let other_pane_id = if active_pane_id == PaneId::Terminal(2) {
+ PaneId::Terminal(3)
+ } else {
+ PaneId::Terminal(2)
+ };
+ let other_info = tab
+ .get_pane_info(other_pane_id)
+ .expect("pane info for the other floating pane");
+ assert!(
+ !other_info.is_fullscreen,
+ "A non-fullscreen floating pane does not report is_fullscreen"
+ );
+}
+
+#[test]
+pub fn toggle_floating_fullscreen_by_pane_id() {
+ let mut tab = create_tab_with_two_floating_panes();
+
+ tab.toggle_pane_fullscreen(PaneId::Terminal(2));
+ assert_eq!(
+ tab.fullscreen_pane_id(),
+ Some(PaneId::Terminal(2)),
+ "By-id fullscreen targets the requested floating pane"
+ );
+
+ tab.toggle_pane_fullscreen(PaneId::Terminal(2));
+ assert!(
+ !tab.is_fullscreen_active(),
+ "By-id toggle off clears floating fullscreen"
+ );
+}
+
+fn create_tab_with_four_panes() -> Tab {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let stacked_resize = false;
+ let mut tab = create_new_tab(size, stacked_resize);
+ for i in 2..5 {
+ let new_pane_id = PaneId::Terminal(i);
+ tab.new_pane(
+ new_pane_id,
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+ }
+ tab
+}
+
+fn tiled_pane_geoms(tab: &Tab) -> Vec<(PaneId, PaneGeom)> {
+ tab.tiled_panes
+ .panes
+ .iter()
+ .map(|(pane_id, pane)| (*pane_id, pane.position_and_size()))
+ .collect()
+}
+
+fn open_pane_five(tab: &mut Tab) {
+ tab.new_pane(
+ PaneId::Terminal(5),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+}
+
+fn assert_panes_tile_the_whole_display(tab: &Tab, display: Size) {
+ let geoms = tiled_pane_geoms(tab);
+ let mut total_pane_area = 0;
+ for (pane_id, geom) in &geoms {
+ let right_edge = geom.x + geom.cols.as_usize();
+ let bottom_edge = geom.y + geom.rows.as_usize();
+ assert!(
+ right_edge <= display.cols && bottom_edge <= display.rows,
+ "{pane_id:?} fits inside the display: {geom:?}"
+ );
+ total_pane_area += geom.rows.as_usize() * geom.cols.as_usize();
+ }
+ for (pane_id, geom) in &geoms {
+ for (other_pane_id, other_geom) in &geoms {
+ if pane_id == other_pane_id {
+ continue;
+ }
+ let overlap_horizontally = geom.x < other_geom.x + other_geom.cols.as_usize()
+ && other_geom.x < geom.x + geom.cols.as_usize();
+ let overlap_vertically = geom.y < other_geom.y + other_geom.rows.as_usize()
+ && other_geom.y < geom.y + geom.rows.as_usize();
+ assert!(
+ !(overlap_horizontally && overlap_vertically),
+ "{pane_id:?} and {other_pane_id:?} do not overlap"
+ );
+ }
+ }
+ assert_eq!(
+ total_pane_area,
+ display.rows * display.cols,
+ "Panes cover the whole display with no gaps"
+ );
+}
+
+#[test]
+pub fn opening_new_pane_while_fullscreen_unsets_fullscreen() {
+ let mut tab = create_tab_with_four_panes();
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active before split");
+ open_pane_five(&mut tab);
+
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Opening a new pane breaks out of fullscreen"
+ );
+ assert_eq!(
+ tiled_pane_geoms(&tab).len(),
+ 5,
+ "All panes including the new one are tiled"
+ );
+ assert_panes_tile_the_whole_display(
+ &tab,
+ Size {
+ cols: 121,
+ rows: 20,
+ },
+ );
+}
+
+#[test]
+pub fn opening_new_pane_while_no_ui_fullscreen_unsets_fullscreen() {
+ let mut tab = create_tab_with_four_panes();
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "NoUi fullscreen active before split"
+ );
+ open_pane_five(&mut tab);
+
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Opening a new pane breaks out of no-ui fullscreen"
+ );
+ assert!(
+ !tab.fullscreen_covers_ui(),
+ "NoUi flag cleared after breaking out"
+ );
+ assert_eq!(
+ tiled_pane_geoms(&tab).len(),
+ 5,
+ "All panes including the new one are tiled"
+ );
+ assert_panes_tile_the_whole_display(
+ &tab,
+ Size {
+ cols: 121,
+ rows: 20,
+ },
+ );
+}
+
+#[test]
+pub fn closing_the_fullscreen_pane_restores_remaining_layout() {
+ let mut tab_without_fullscreen = create_tab_with_four_panes();
+ tab_without_fullscreen.close_pane(PaneId::Terminal(4), false, None);
+
+ let mut tab = create_tab_with_four_panes();
+ tab.toggle_active_pane_fullscreen(1);
+ assert!(tab.is_fullscreen_active(), "Fullscreen active before close");
+ tab.close_pane(PaneId::Terminal(4), false, None);
+
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Closing the fullscreen pane leaves fullscreen"
+ );
+ assert_eq!(
+ tiled_pane_geoms(&tab),
+ tiled_pane_geoms(&tab_without_fullscreen),
+ "Remaining panes match a close that never went through fullscreen"
+ );
+}
+
+#[test]
+pub fn closing_the_no_ui_fullscreen_pane_restores_remaining_layout() {
+ let mut tab_without_fullscreen = create_tab_with_four_panes();
+ tab_without_fullscreen.close_pane(PaneId::Terminal(4), false, None);
+
+ let mut tab = create_tab_with_four_panes();
+ tab.toggle_active_pane_no_ui_fullscreen(1);
+ assert!(
+ tab.fullscreen_covers_ui(),
+ "NoUi fullscreen active before close"
+ );
+ tab.close_pane(PaneId::Terminal(4), false, None);
+
+ assert!(
+ !tab.is_fullscreen_active(),
+ "Closing the no-ui fullscreen pane leaves fullscreen"
+ );
+ assert!(
+ !tab.fullscreen_covers_ui(),
+ "NoUi flag cleared after the close"
+ );
+ assert_eq!(
+ tiled_pane_geoms(&tab),
+ tiled_pane_geoms(&tab_without_fullscreen),
+ "Remaining panes match a close that never went through no-ui fullscreen"
+ );
+}
+
+#[test]
pub fn closing_fullscreen_scrollback_editor_restores_consistent_layout() {
// Replacing a pane (e.g. opening or closing a scrollback editor) swaps
// the pane id occupying its tiled slot. If the replaced pane was the
diff --git a/zellij-server/src/ui/components/mod.rs b/zellij-server/src/ui/components/mod.rs
index bc977f1c..ad927f0e 100644
--- a/zellij-server/src/ui/components/mod.rs
+++ b/zellij-server/src/ui/components/mod.rs
@@ -157,22 +157,30 @@ impl<'a> UiComponentParser<'a> {
}
}
-fn parse_selected(stringified: &mut String) -> bool {
- let mut selected = false;
- if stringified.chars().next() == Some('x') {
- selected = true;
- stringified.remove(0);
+fn parse_flag(stringified: &mut String, flag: char) -> bool {
+ const FLAG_CHARS: [char; 3] = ['x', 'z', 'd'];
+ let prefix_len = stringified
+ .chars()
+ .take_while(|c| FLAG_CHARS.contains(c))
+ .count();
+ if let Some(position) = stringified[..prefix_len].find(flag) {
+ stringified.remove(position);
+ true
+ } else {
+ false
}
- selected
+}
+
+fn parse_selected(stringified: &mut String) -> bool {
+ parse_flag(stringified, 'x')
}
fn parse_opaque(stringified: &mut String) -> bool {
- let mut opaque = false;
- if stringified.chars().next() == Some('z') {
- opaque = true;
- stringified.remove(0);
- }
- opaque
+ parse_flag(stringified, 'z')
+}
+
+fn parse_disabled(stringified: &mut String) -> bool {
+ parse_flag(stringified, 'd')
}
fn parse_indices(stringified: &mut String) -> Vec<Vec<usize>> {
diff --git a/zellij-server/src/ui/components/nested_list.rs b/zellij-server/src/ui/components/nested_list.rs
index b4b76b9b..a4fcabb7 100644
--- a/zellij-server/src/ui/components/nested_list.rs
+++ b/zellij-server/src/ui/components/nested_list.rs
@@ -89,6 +89,7 @@ pub fn parse_nested_list_items<'a>(
text,
opaque,
selected,
+ disabled: false,
indices,
};
Ok::<NestedListItem, String>(NestedListItem {
diff --git a/zellij-server/src/ui/components/ribbon.rs b/zellij-server/src/ui/components/ribbon.rs
index adfa3a79..91cc62e3 100644
--- a/zellij-server/src/ui/components/ribbon.rs
+++ b/zellij-server/src/ui/components/ribbon.rs
@@ -12,6 +12,46 @@ pub fn ribbon(
) -> Vec<u8> {
let colors = style.colors;
let background = colors.text_unselected.background;
+ if content.disabled {
+ let declaration = colors.ribbon_unselected;
+ let disabled_content = content.into_disabled();
+ let (first_arrow_styles, text_style, last_arrow_styles) = (
+ character_style(background, declaration.background),
+ RESET_STYLES
+ .foreground(Some(declaration.base.into()))
+ .background(Some(declaration.background.into()))
+ .italic(Some(AnsiCode::On)),
+ character_style(declaration.background, background),
+ );
+ let (arrow, padding) = if arrow_fonts {
+ (ARROW_SEPARATOR, Some(4))
+ } else {
+ ("", None)
+ };
+ let (text, _text_width) = stringify_text(
+ &disabled_content,
+ padding,
+ &component_coordinates,
+ &declaration,
+ &colors,
+ text_style,
+ );
+ let mut stringified = component_coordinates
+ .map(|c| c.to_string())
+ .unwrap_or_else(|| String::new());
+ stringified.push_str(&format!(
+ "{}{}{}{} {} {}{}{}",
+ RESET_STYLES,
+ first_arrow_styles,
+ arrow,
+ text_style,
+ text,
+ last_arrow_styles,
+ arrow,
+ RESET_STYLES
+ ));
+ return stringified.as_bytes().to_vec();
+ }
let declaration = if content.selected {
colors.ribbon_selected
} else {
diff --git a/zellij-server/src/ui/components/text.rs b/zellij-server/src/ui/components/text.rs
index eb8667f8..4209d433 100644
--- a/zellij-server/src/ui/components/text.rs
+++ b/zellij-server/src/ui/components/text.rs
@@ -1,4 +1,7 @@
-use super::{is_too_wide, parse_indices, parse_opaque, parse_selected, Coordinates};
+use super::{
+ is_too_wide, parse_disabled, parse_indices, parse_opaque, parse_selected, Coordinates,
+};
+use crate::panes::terminal_character::RESET_STYLES;
use crate::panes::{terminal_character::CharacterStyles, AnsiCode};
use zellij_utils::{
data::{PaletteColor, Style, StyleDeclaration},
@@ -9,6 +12,32 @@ use unicode_width::UnicodeWidthChar;
use zellij_utils::errors::prelude::*;
pub fn text(content: Text, style: &Style, component_coordinates: Option<Coordinates>) -> Vec<u8> {
+ if content.disabled {
+ let declaration = style.colors.text_unselected;
+ let mut base_text_style = RESET_STYLES
+ .foreground(Some(declaration.base.into()))
+ .italic(Some(AnsiCode::On));
+ if content.opaque || content.selected {
+ base_text_style = base_text_style.background(Some(declaration.background.into()));
+ }
+ let disabled_content = content.into_disabled();
+ let (text, _text_width) = stringify_text(
+ &disabled_content,
+ None,
+ &component_coordinates,
+ &declaration,
+ &style.colors,
+ base_text_style,
+ );
+ return match component_coordinates {
+ Some(component_coordinates) => {
+ format!("{}{}{}", component_coordinates, base_text_style, text)
+ .as_bytes()
+ .to_vec()
+ },
+ None => format!("{}{}", base_text_style, text).as_bytes().to_vec(),
+ };
+ }
let declaration = if content.selected {
style.colors.text_selected
} else {
@@ -138,12 +167,14 @@ pub fn parse_text_params<'a>(params_iter: impl Iterator<Item = &'a mut String>)
.flat_map(|mut stringified| {
let selected = parse_selected(&mut stringified);
let opaque = parse_opaque(&mut stringified);
+ let disabled = parse_disabled(&mut stringified);
let indices = parse_indices(&mut stringified);
let text = parse_text(&mut stringified).map_err(|e| e.to_string())?;
Ok::<Text, String>(Text {
text,
opaque,
selected,
+ disabled,
indices,
})
})
@@ -155,10 +186,19 @@ pub struct Text {
pub text: String,
pub selected: bool,
pub opaque: bool,
+ pub disabled: bool,
pub indices: Vec<Vec<usize>>,
}
impl Text {
+ pub fn into_disabled(mut self) -> Self {
+ self.selected = false;
+ self.opaque = false;
+ self.disabled = true;
+ self.indices = vec![];
+ self
+ }
+
pub fn pad_text(&mut self, max_column_width: usize) {
for _ in ansi_len(&self.text)..max_column_width {
self.text.push(' ');
diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs
index 5c62bfa0..f5347f3f 100644
--- a/zellij-server/src/ui/pane_boundaries_frame.rs
+++ b/zellij-server/src/ui/pane_boundaries_frame.rs
@@ -2,6 +2,7 @@ use crate::output::CharacterChunk;
use crate::panes::{
AnsiCode, CharacterStyles, RcCharacterStyles, TerminalCharacter, EMPTY_TERMINAL_CHARACTER,
};
+use crate::tab::GuestChoiceIndicator;
use crate::ui::boundaries::boundary_type;
use crate::ui::hint_text::{
exit_code_segments, hover_segments, rerun_segments, resize_segments, HintExitStatus, HintLevel,
@@ -96,6 +97,8 @@ pub struct FrameParams {
pub stack_list_entry: Option<StackListEntry>,
pub blank_title: bool,
pub mouse_scroll_resize: bool,
+ pub dimmed: bool,
+ pub guest_choice_indicator: Option<GuestChoiceIndicator>,
}
#[derive(Default, PartialEq)]
@@ -126,6 +129,8 @@ pub struct PaneFrame {
stack_list_entry: Option<StackListEntry>,
color_override: Option<PaletteColor>,
mouse_scroll_resize: bool,
+ dimmed: bool,
+ guest_choice_indicator: Option<GuestChoiceIndicator>,
}
impl PaneFrame {
@@ -162,6 +167,8 @@ impl PaneFrame {
stack_list_entry: frame_params.stack_list_entry,
color_override: None,
mouse_scroll_resize: frame_params.mouse_scroll_resize,
+ dimmed: frame_params.dimmed,
+ guest_choice_indicator: frame_params.guest_choice_indicator,
}
}
pub fn is_pinned(mut self, is_pinned: bool) -> Self {
@@ -213,6 +220,12 @@ impl PaneFrame {
&self,
max_length: usize,
) -> Option<(Vec<TerminalCharacter>, usize)> {
+ self.render_title_right_side_inner(max_length)
+ }
+ fn render_title_right_side_inner(
+ &self,
+ max_length: usize,
+ ) -> Option<(Vec<TerminalCharacter>, usize)> {
// string and length because of color
let has_scroll = self.scroll_position.0 > 0 || self.scroll_position.1 > 0;
if has_scroll && self.is_selectable {
@@ -844,9 +857,9 @@ impl PaneFrame {
let side_budget = width.saturating_sub(title_length) / 2;
let focus = self.bracketed_focus_indicator(side_budget);
let focus_length = focus.as_ref().map(|(_, length)| *length).unwrap_or(0);
- let scroll =
- self.bracketed_scroll_indicator(width.saturating_sub(focus_length + title_length));
- Ok(self.compose_bracketed_title(focus, title, scroll))
+ let right_budget = width.saturating_sub(focus_length + title_length);
+ let right = self.bracketed_scroll_indicator(right_budget);
+ Ok(self.compose_bracketed_title(focus, title, right))
}
fn bracketed_title_part(&self, content: &str) -> (Vec<TerminalCharacter>, usize) {
let text = format!(" [ {} ] ", content);
@@ -1466,6 +1479,8 @@ mod tests {
stack_list_entry: None,
blank_title: false,
mouse_scroll_resize,
+ dimmed: false,
+ guest_choice_indicator: None,
},
)
}
diff --git a/zellij-server/src/ui/pane_contents_and_ui.rs b/zellij-server/src/ui/pane_contents_and_ui.rs
index 668caec3..a6a401a1 100644
--- a/zellij-server/src/ui/pane_contents_and_ui.rs
+++ b/zellij-server/src/ui/pane_contents_and_ui.rs
@@ -1,4 +1,5 @@
-use crate::output::Output;
+use crate::output::{CharacterChunk, Output};
+use crate::panes::terminal_character::AnsiCode;
use crate::panes::PaneId;
use crate::tab::Pane;
use crate::ui::boundaries::Boundaries;
@@ -8,6 +9,17 @@ use std::collections::{HashMap, HashSet};
use zellij_utils::data::{client_id_to_colors, InputMode, PaletteColor, Style};
use zellij_utils::errors::prelude::*;
use zellij_utils::pane_size::PaneGeom;
+
+pub fn dim_character_chunks(character_chunks: &mut Vec<CharacterChunk>) {
+ for chunk in character_chunks.iter_mut() {
+ for terminal_character in chunk.terminal_characters.iter_mut() {
+ terminal_character.styles.update(|styles| {
+ styles.dim = Some(AnsiCode::On);
+ });
+ }
+ }
+}
+
pub struct PaneContentsAndUi<'a> {
pane: &'a mut Box<dyn Pane>,
output: &'a mut Output,
@@ -28,6 +40,7 @@ pub struct PaneContentsAndUi<'a> {
stack_list_entry_stack_is_focused: bool,
blank_title: bool,
mouse_scroll_resize: bool,
+ dimmed_for_clients: HashSet<ClientId>,
}
impl<'a> PaneContentsAndUi<'a> {
@@ -46,6 +59,7 @@ impl<'a> PaneContentsAndUi<'a> {
show_help_text: bool,
omit_title: bool,
mouse_scroll_resize: bool,
+ dimmed_for_clients: HashSet<ClientId>,
) -> Self {
let mut focused_clients: Vec<ClientId> = active_panes
.iter()
@@ -83,8 +97,12 @@ impl<'a> PaneContentsAndUi<'a> {
stack_list_entry_stack_is_focused: false,
blank_title: false,
mouse_scroll_resize,
+ dimmed_for_clients,
}
}
+ fn frame_is_dimmed_for_client(&self, client_id: ClientId) -> bool {
+ self.dimmed_for_clients.contains(&client_id) && !self.focused_clients.contains(&client_id)
+ }
pub fn set_frame_geom_override(&mut self, frame_geom_override: Option<PaneGeom>) {
self.frame_geom_override = frame_geom_override;
}
@@ -173,6 +191,40 @@ impl<'a> PaneContentsAndUi<'a> {
}
Ok(())
}
+ pub fn client_has_guest_modal(&self, client_id: ClientId) -> bool {
+ self.pane.guest_modal_selection(client_id).is_some()
+ }
+ pub fn drain_pane_render_state(&mut self) {
+ drop(self.pane.drain_fake_cursors());
+ let _ = self.pane.render(None);
+ }
+ pub fn render_guest_modal_for_client(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to render guest modal for client {client_id}");
+ let selection = self.pane.guest_modal_selection(client_id).unwrap_or(0);
+ let session_name = self
+ .pane
+ .guest_session_name()
+ .unwrap_or_else(|| String::from("unknown"));
+ let columns = self.pane.get_content_columns();
+ let rows = self.pane.get_content_rows();
+ let content_x = self.pane.get_content_x();
+ let content_y = self.pane.get_content_y();
+ let shortcuts = self.pane.guest_modal_shortcuts();
+ let chunks = crate::panes::nested_session_modal::guest_modal_chunks(
+ columns,
+ rows,
+ content_x,
+ content_y,
+ &self.style,
+ &session_name,
+ selection,
+ &shortcuts,
+ );
+ self.output
+ .add_character_chunks_to_client(client_id, chunks, self.z_index)
+ .with_context(err_context)?;
+ Ok(())
+ }
pub fn render_fake_cursor_if_needed(&mut self, client_id: ClientId) -> Result<()> {
let pane_focused_for_client_id = self.focused_clients.contains(&client_id);
let pane_focused_for_different_client = self
@@ -289,6 +341,8 @@ impl<'a> PaneContentsAndUi<'a> {
.contains(&client_id)
&& !pane_focused_for_client_id),
});
+ let frame_is_dimmed = self.frame_is_dimmed_for_client(client_id);
+ let guest_choice_indicator = self.pane.guest_choice_indicator(client_id);
let frame_params = if session_is_mirrored {
FrameParams {
focused_client,
@@ -314,6 +368,8 @@ impl<'a> PaneContentsAndUi<'a> {
stack_list_entry: stack_list_entry.clone(),
blank_title: self.blank_title,
mouse_scroll_resize: self.mouse_scroll_resize,
+ dimmed: frame_is_dimmed,
+ guest_choice_indicator,
}
} else {
FrameParams {
@@ -340,14 +396,19 @@ impl<'a> PaneContentsAndUi<'a> {
stack_list_entry,
blank_title: self.blank_title,
mouse_scroll_resize: self.mouse_scroll_resize,
+ dimmed: frame_is_dimmed,
+ guest_choice_indicator,
}
};
- if let Some((frame_terminal_characters, vte_output)) = self
+ if let Some((mut frame_terminal_characters, vte_output)) = self
.pane
.render_frame(client_id, frame_params, client_mode)
.with_context(err_context)?
{
+ if self.frame_is_dimmed_for_client(client_id) {
+ dim_character_chunks(&mut frame_terminal_characters);
+ }
self.output
.add_character_chunks_to_client(client_id, frame_terminal_characters, self.z_index)
.with_context(err_context)?;
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 03ed31de..9d839d30 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -18,7 +18,7 @@ use zellij_utils::input::layout::{
RunPlugin, RunPluginLocation, RunPluginOrAlias, SplitDirection, TiledPaneLayout,
};
use zellij_utils::input::mouse::MouseEvent;
-use zellij_utils::input::options::{Options, PaneFrameStyle};
+use zellij_utils::input::options::{NestedSessionHandling, Options, PaneFrameStyle};
use zellij_utils::ipc::IpcReceiverWithContext;
use zellij_utils::pane_size::{Size, SizeInPixels};
use zellij_utils::position::Position;
@@ -319,6 +319,7 @@ fn create_new_screen(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ NestedSessionHandling::default(),
);
screen
}
@@ -329,6 +330,7 @@ struct MockScreen {
pub pty_writer_receiver: Option<Receiver<(PtyWriteInstruction, ErrorContext)>>,
#[allow(dead_code)]
pub background_jobs_receiver: Option<Receiver<(BackgroundJob, ErrorContext)>>,
+ pub received_background_jobs: Arc<Mutex<Vec<BackgroundJob>>>,
pub screen_receiver: Option<Receiver<(ScreenInstruction, ErrorContext)>>,
pub server_receiver: Option<Receiver<(ServerInstruction, ErrorContext)>>,
pub plugin_receiver: Option<Receiver<(PluginInstruction, ErrorContext)>>,
@@ -639,6 +641,7 @@ impl MockScreen {
current_input_modes: self.session_metadata.current_input_modes.clone(),
web_sharing: WebSharing::Off,
config_file_path: self.session_metadata.config_file_path.clone(),
+ key_passthrough_clients: self.session_metadata.key_passthrough_clients.clone(),
}
}
}
@@ -692,6 +695,7 @@ impl MockScreen {
current_input_modes: HashMap::new(),
web_sharing: WebSharing::Off,
config_file_path: None,
+ key_passthrough_clients: Default::default(),
};
let os_input = FakeInputOutput::default();
@@ -701,14 +705,17 @@ impl MockScreen {
config.options.stacked_pane_list = Some(false);
let main_client_id = 1;
+ let received_background_jobs = Arc::new(Mutex::new(vec![]));
std::thread::Builder::new()
.name("background_jobs_thread".to_string())
.spawn({
let to_screen = to_screen.clone();
+ let received_background_jobs = received_background_jobs.clone();
move || loop {
let (event, _err_ctx) = background_jobs_receiver
.recv()
.expect("failed to receive event on channel");
+ received_background_jobs.lock().unwrap().push(event.clone());
match event {
BackgroundJob::RenderToClients => {
let _ = to_screen.send(ScreenInstruction::RenderToClients);
@@ -726,6 +733,7 @@ impl MockScreen {
pty_receiver: Some(pty_receiver),
pty_writer_receiver: Some(pty_writer_receiver),
background_jobs_receiver: None,
+ received_background_jobs,
screen_receiver: Some(screen_receiver),
server_receiver: Some(server_receiver),
plugin_receiver: Some(plugin_receiver),
@@ -2039,6 +2047,72 @@ fn mouse_focus_clears_bell_on_focused_pane() {
}
#[test]
+fn nested_guest_fullscreen_moves_from_one_pane_to_another() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut screen = create_new_screen(size, true, true);
+
+ new_tab(&mut screen, 1, 0);
+ let pane_one = PaneId::Terminal(1);
+ let pane_two = PaneId::Terminal(2);
+ {
+ let active_tab = screen.get_active_tab_mut(client_id).unwrap();
+ active_tab
+ .horizontal_split(pane_two, None, client_id, None, None)
+ .unwrap();
+ }
+
+ screen.apply_nested_guest_fullscreen(pane_one, true);
+ {
+ let active_tab = screen.get_active_tab(client_id).unwrap();
+ assert_eq!(
+ active_tab.fullscreen_pane_id(),
+ Some(pane_one),
+ "the first guest pane is fullscreen"
+ );
+ assert!(active_tab.fullscreen_covers_ui());
+ }
+ assert_eq!(
+ screen.nested_fullscreen_panes,
+ [pane_one].into_iter().collect(),
+ "only the first guest pane is tracked as fullscreen"
+ );
+
+ screen.apply_nested_guest_fullscreen(pane_two, true);
+ {
+ let active_tab = screen.get_active_tab(client_id).unwrap();
+ assert_eq!(
+ active_tab.fullscreen_pane_id(),
+ Some(pane_two),
+ "fullscreen moved to the second guest pane"
+ );
+ assert!(active_tab.fullscreen_covers_ui());
+ }
+ assert_eq!(
+ screen.nested_fullscreen_panes,
+ [pane_two].into_iter().collect(),
+ "the first guest pane is no longer tracked as fullscreen, only the second is"
+ );
+
+ screen.apply_nested_guest_fullscreen(pane_two, false);
+ {
+ let active_tab = screen.get_active_tab(client_id).unwrap();
+ assert_eq!(
+ active_tab.fullscreen_pane_id(),
+ None,
+ "unsetting fullscreen restores the tiled layout"
+ );
+ }
+ assert!(
+ screen.nested_fullscreen_panes.is_empty(),
+ "no guest panes are tracked as fullscreen after unsetting"
+ );
+}
+
+#[test]
fn group_panes_with_keyboard() {
let size = Size {
cols: 121,
@@ -5452,6 +5526,7 @@ fn create_new_screen_with_message_capture(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ NestedSessionHandling::default(),
);
(screen, messages)
}
@@ -8460,6 +8535,19 @@ impl ForwardCapture {
}
out
}
+
+ /// Drain every pending `ServerInstruction::KeyPassthroughChanged`,
+ /// returning the `notify_guest` flag for each. Other variants are
+ /// dropped.
+ fn drain_key_passthrough_notify_flags(&self) -> Vec<bool> {
+ let mut out = Vec::new();
+ while let Ok((instr, _ctx)) = self.server_rx.try_recv() {
+ if let ServerInstruction::KeyPassthroughChanged(_, _, _, _, _, notify_guest) = instr {
+ out.push(notify_guest);
+ }
+ }
+ out
+ }
}
fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture) {
@@ -8535,6 +8623,7 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture
false, // mouse_click_through
web_server_ip,
web_server_port,
+ NestedSessionHandling::default(),
);
(
screen,
@@ -8681,6 +8770,92 @@ fn handle_reply_dispatches_next_queued_forward() {
}
#[test]
+fn clear_nested_guest_does_not_notify_guest_focus_lost_on_teardown() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane_id = PaneId::Terminal(7);
+ let client_id = 1;
+ screen
+ .nested_guest_choices
+ .insert((client_id, pane_id), super::NestedGuestChoice::Descend);
+
+ screen.clear_nested_guest(pane_id);
+
+ let notify_flags = capture.drain_key_passthrough_notify_flags();
+ assert_eq!(
+ notify_flags,
+ vec![false],
+ "teardown clear must emit KeyPassthroughChanged with notify_guest=false so no FocusLost \
+ frame is written to the exiting guest pane"
+ );
+}
+
+#[test]
+fn remove_client_notifies_guest_focus_lost_on_live_ascend() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane_id = PaneId::Terminal(7);
+ let client_id = 1;
+ screen
+ .nested_guest_choices
+ .insert((client_id, pane_id), super::NestedGuestChoice::Descend);
+
+ screen.remove_client(client_id).expect("remove_client ok");
+
+ let notify_flags = capture.drain_key_passthrough_notify_flags();
+ assert_eq!(
+ notify_flags,
+ vec![true],
+ "a live client leaving a still-alive guest must emit notify_guest=true so the guest is \
+ told it lost focus"
+ );
+}
+
+#[test]
+fn suspend_nested_guest_preserves_choices_for_later_revival() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, _capture) = create_new_screen_with_forward_capture(size);
+ let pane_id = PaneId::Terminal(7);
+ let client_id = 1;
+ screen
+ .nested_guest_choices
+ .insert((client_id, pane_id), super::NestedGuestChoice::Descend);
+ screen
+ .nested_guest_tracker
+ .on_announce(pane_id, std::time::Instant::now());
+
+ screen.suspend_nested_guest(pane_id);
+
+ assert!(
+ screen
+ .nested_guest_choices
+ .contains_key(&(client_id, pane_id)),
+ "suspend must preserve the client's descend choice so a re-announcing guest can be revived \
+ into the exact prior state"
+ );
+}
+
+#[test]
+fn clear_nested_guest_discards_choices_unlike_suspend() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, _capture) = create_new_screen_with_forward_capture(size);
+ let pane_id = PaneId::Terminal(7);
+ let client_id = 1;
+ screen
+ .nested_guest_choices
+ .insert((client_id, pane_id), super::NestedGuestChoice::Descend);
+
+ screen.clear_nested_guest(pane_id);
+
+ assert!(
+ !screen
+ .nested_guest_choices
+ .contains_key(&(client_id, pane_id)),
+ "a full teardown (Bye/ClosePane) must discard choices, distinguishing it from suspend"
+ );
+}
+
+#[test]
fn handle_reply_with_unknown_token_is_silent_noop() {
// Token not in the map AND not the in-flight token: the handler
// must not panic, must not write any bytes, and crucially must
@@ -9120,6 +9295,7 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) {
false,
web_server_ip,
web_server_port,
+ NestedSessionHandling::default(),
);
(
screen,
@@ -9243,7 +9419,7 @@ fn color_palette_mode_query_short_circuits_to_light_reply() {
}
#[test]
-fn color_palette_mode_query_stays_silent_when_host_mode_unknown() {
+fn color_palette_mode_query_falls_back_to_own_dark_theme_when_host_mode_unknown() {
use crate::host_query::HostQuery;
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
@@ -9251,14 +9427,60 @@ fn color_palette_mode_query_stays_silent_when_host_mode_unknown() {
screen.host_terminal_theme_mode.is_none(),
"precondition: no host mode learned yet"
);
+ screen.style.colors.text_unselected.background =
+ zellij_utils::data::PaletteColor::Rgb((0, 0, 0));
- let _ = screen.forward_host_query(PaneId::Terminal(1), HostQuery::ColorPaletteMode);
+ let token = screen.forward_host_query(PaneId::Terminal(1), HostQuery::ColorPaletteMode);
+ assert_eq!(
+ token, 0,
+ "ColorPaletteMode must return the sentinel token; no real forward was queued"
+ );
assert!(
- capture.drain_pty_writes().is_empty(),
- "Contour spec defines only ;1 (dark) and ;2 (light); when Zellij has \
- not learned the host's mode it must stay silent rather than fabricate \
- a non-conformant reply (e.g. ;0)"
+ capture.drain_forward_queries().is_empty(),
+ "must NOT forward to host — Zellij answers from its own effective theme"
+ );
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1, "exactly one pty reply expected");
+ assert_eq!(writes[0], (b"\x1b[?997;1n".to_vec(), 1));
+}
+
+#[test]
+fn color_palette_mode_query_falls_back_to_own_light_theme_when_host_mode_unknown() {
+ use crate::host_query::HostQuery;
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ assert!(
+ screen.host_terminal_theme_mode.is_none(),
+ "precondition: no host mode learned yet"
+ );
+ screen.style.colors.text_unselected.background =
+ zellij_utils::data::PaletteColor::Rgb((255, 255, 255));
+
+ let _ = screen.forward_host_query(PaneId::Terminal(7), HostQuery::ColorPaletteMode);
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1);
+ assert_eq!(writes[0], (b"\x1b[?997;2n".to_vec(), 7));
+}
+
+#[test]
+fn color_palette_mode_query_prefers_known_host_mode_over_own_theme() {
+ use crate::host_query::HostQuery;
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.host_terminal_theme_mode = Some(zellij_utils::data::HostTerminalThemeMode::Light);
+ screen.style.colors.text_unselected.background =
+ zellij_utils::data::PaletteColor::Rgb((0, 0, 0));
+
+ let _ = screen.forward_host_query(PaneId::Terminal(9), HostQuery::ColorPaletteMode);
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1);
+ assert_eq!(
+ writes[0],
+ (b"\x1b[?997;2n".to_vec(), 9),
+ "the host's announced mode must win over the local theme fallback"
);
}
@@ -9601,6 +9823,7 @@ fn create_non_mirrored_screen(size: Size) -> Screen {
false, // mouse_click_through
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
+ NestedSessionHandling::default(),
)
}
@@ -11171,3 +11394,176 @@ fn exiting_mobile_releases_the_plugin_render() {
"exiting mobile must release the plugin render; sent={sent:?}",
);
}
+
+fn decode_nested_frame(bytes: &[u8]) -> Option<zellij_utils::nested_session::NestedSessionMessage> {
+ use zellij_utils::nested_session::{
+ decode_base64, decode_payload, NESTED_FRAME_HEADER, NESTED_FRAME_TERMINATOR,
+ };
+ let encoded_payload = bytes
+ .strip_prefix(NESTED_FRAME_HEADER)?
+ .strip_suffix(NESTED_FRAME_TERMINATOR)?;
+ decode_payload(&decode_base64(encoded_payload)?)
+}
+
+fn guest_announce_message() -> zellij_utils::nested_session::NestedSessionMessage {
+ zellij_utils::nested_session::NestedSessionMessage::Announce {
+ session_name: "guest-session".to_owned(),
+ capabilities: vec![zellij_utils::nested_session::NestedSessionCapability::NestedControl],
+ }
+}
+
+#[test]
+pub fn nested_guest_announce_gets_announce_ack_with_ancestry() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut mock_screen = MockScreen::new(size);
+ let pty_writer_receiver = mock_screen.pty_writer_receiver.take().unwrap();
+ let received_background_jobs = mock_screen.received_background_jobs.clone();
+ let screen_thread = mock_screen.run(None, vec![]);
+ let received_pty_instructions = Arc::new(Mutex::new(vec![]));
+ let pty_writer_thread = log_actions_in_thread!(
+ received_pty_instructions,
+ PtyWriteInstruction::Exit,
+ pty_writer_receiver
+ );
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::NestedSessionMessageFromPane {
+ pane_id: PaneId::Terminal(0),
+ message: guest_announce_message(),
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ mock_screen.teardown(vec![pty_writer_thread, screen_thread]);
+ let announce_ack = received_pty_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|instruction| match instruction {
+ PtyWriteInstruction::Write(bytes, 0, None) => decode_nested_frame(bytes),
+ _ => None,
+ })
+ .expect("an announce_ack frame written to the guest pane");
+ assert_eq!(
+ announce_ack,
+ zellij_utils::nested_session::NestedSessionMessage::AnnounceAck {
+ ancestry: vec!["zellij-test".to_owned()],
+ capabilities: vec![
+ zellij_utils::nested_session::NestedSessionCapability::NestedControl
+ ],
+ descend_keys: vec![],
+ }
+ );
+ assert!(received_background_jobs
+ .lock()
+ .unwrap()
+ .iter()
+ .any(|job| matches!(
+ job,
+ BackgroundJob::StartNestedGuestPing(PaneId::Terminal(0))
+ )));
+}
+
+#[test]
+pub fn nested_guest_announce_in_never_mode_still_completes_handshake() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.config.options.nested_session_handling = Some(NestedSessionHandling::Never);
+ let pty_writer_receiver = mock_screen.pty_writer_receiver.take().unwrap();
+ let received_background_jobs = mock_screen.received_background_jobs.clone();
+ let screen_thread = mock_screen.run(None, vec![]);
+ let received_pty_instructions = Arc::new(Mutex::new(vec![]));
+ let pty_writer_thread = log_actions_in_thread!(
+ received_pty_instructions,
+ PtyWriteInstruction::Exit,
+ pty_writer_receiver
+ );
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::NestedSessionMessageFromPane {
+ pane_id: PaneId::Terminal(0),
+ message: guest_announce_message(),
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ mock_screen.teardown(vec![pty_writer_thread, screen_thread]);
+ let announce_ack = received_pty_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|instruction| match instruction {
+ PtyWriteInstruction::Write(bytes, 0, None) => decode_nested_frame(bytes),
+ _ => None,
+ })
+ .expect("an announce_ack frame written to the guest pane even in never mode");
+ assert!(matches!(
+ announce_ack,
+ zellij_utils::nested_session::NestedSessionMessage::AnnounceAck { .. }
+ ));
+ assert!(received_background_jobs
+ .lock()
+ .unwrap()
+ .iter()
+ .any(|job| matches!(
+ job,
+ BackgroundJob::StartNestedGuestPing(PaneId::Terminal(0))
+ )));
+}
+
+#[test]
+pub fn nested_guest_bye_stops_liveness_pings() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut mock_screen = MockScreen::new(size);
+ let pty_writer_receiver = mock_screen.pty_writer_receiver.take().unwrap();
+ let received_background_jobs = mock_screen.received_background_jobs.clone();
+ let screen_thread = mock_screen.run(None, vec![]);
+ let received_pty_instructions = Arc::new(Mutex::new(vec![]));
+ let pty_writer_thread = log_actions_in_thread!(
+ received_pty_instructions,
+ PtyWriteInstruction::Exit,
+ pty_writer_receiver
+ );
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::NestedSessionMessageFromPane {
+ pane_id: PaneId::Terminal(0),
+ message: guest_announce_message(),
+ });
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::NestedSessionMessageFromPane {
+ pane_id: PaneId::Terminal(0),
+ message: zellij_utils::nested_session::NestedSessionMessage::Bye,
+ });
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::NestedGuestPingTick {
+ pane_id: PaneId::Terminal(0),
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ mock_screen.teardown(vec![pty_writer_thread, screen_thread]);
+ let ping_frames_written = received_pty_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|instruction| match instruction {
+ PtyWriteInstruction::Write(bytes, 0, None) => matches!(
+ decode_nested_frame(bytes),
+ Some(zellij_utils::nested_session::NestedSessionMessage::Ping)
+ ),
+ _ => false,
+ })
+ .count();
+ assert_eq!(ping_frames_written, 0);
+ assert!(received_background_jobs
+ .lock()
+ .unwrap()
+ .iter()
+ .any(|job| matches!(job, BackgroundJob::StopNestedGuestPing(PaneId::Terminal(0)))));
+}
diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs
index 00be00bf..5fdbe915 100644
--- a/zellij-tile/src/shim.rs
+++ b/zellij-tile/src/shim.rs
@@ -1292,6 +1292,20 @@ pub fn toggle_focus_fullscreen() {
unsafe { host_run_plugin_command() };
}
+pub fn toggle_focus_no_ui_fullscreen() {
+ let plugin_command = PluginCommand::ToggleFocusNoUiFullscreen;
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
+pub fn focus_host_session() {
+ let plugin_command = PluginCommand::FocusHostSession;
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
/// Toggle the UI pane frames on or off
pub fn toggle_pane_frames() {
let plugin_command = PluginCommand::TogglePaneFrames;
diff --git a/zellij-tile/src/ui_components/text.rs b/zellij-tile/src/ui_components/text.rs
index ddfbc0e8..fa1533ca 100644
--- a/zellij-tile/src/ui_components/text.rs
+++ b/zellij-tile/src/ui_components/text.rs
@@ -7,6 +7,7 @@ pub struct Text {
text: String,
selected: bool,
opaque: bool,
+ disabled: bool,
indices: Vec<Vec<usize>>,
}
@@ -16,6 +17,7 @@ impl From<StyledText> for Text {
text: styled_text.text,
selected: false,
opaque: false,
+ disabled: false,
indices: styled_text.indices,
}
}
@@ -30,6 +32,7 @@ impl Text {
text: content.to_string(),
selected: false,
opaque: false,
+ disabled: false,
indices: vec![],
}
}
@@ -41,6 +44,10 @@ impl Text {
self.opaque = true;
self
}
+ pub fn disabled(mut self) -> Self {
+ self.disabled = true;
+ self
+ }
pub fn dim_indices(mut self, mut indices: Vec<usize>) -> Self {
const DIM_LEVEL: usize = 4;
self.pad_indices(DIM_LEVEL);
@@ -406,11 +413,15 @@ impl Text {
let mut prefix = "".to_owned();
if self.selected {
- prefix = format!("x{}", prefix);
+ prefix.push('x');
}
if self.opaque {
- prefix = format!("z{}", prefix);
+ prefix.push('z');
+ }
+
+ if self.disabled {
+ prefix.push('d');
}
format!("{}{}{}", prefix, indices, text)
@@ -465,3 +476,45 @@ pub fn serialize_text_with_coordinates(
text.serialize()
)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn text_body(text: &str) -> String {
+ text.as_bytes()
+ .iter()
+ .map(|byte| byte.to_string())
+ .collect::<Vec<_>>()
+ .join(",")
+ }
+
+ #[test]
+ fn disabled_flag_serializes_with_a_d_prefix() {
+ let serialized = Text::new("x").disabled().serialize();
+ assert_eq!(serialized, format!("d{}", text_body("x")));
+ }
+
+ #[test]
+ fn opaque_and_disabled_serialize_in_push_order() {
+ let serialized = Text::new("x").disabled().opaque().serialize();
+ assert_eq!(serialized, format!("zd{}", text_body("x")));
+ }
+
+ #[test]
+ fn all_flags_serialize_in_selected_opaque_disabled_order() {
+ let serialized = Text::new("x").disabled().opaque().selected().serialize();
+ assert_eq!(serialized, format!("xzd{}", text_body("x")));
+ }
+
+ #[test]
+ fn flags_do_not_disturb_indices_or_text() {
+ let serialized = Text::new("Foo bar baz")
+ .disabled()
+ .color_indices(0, vec![0, 1, 2])
+ .serialize();
+ assert!(serialized.starts_with('d'));
+ assert!(serialized.contains("0,1,2$"));
+ assert!(serialized.ends_with(&text_body("Foo bar baz")));
+ }
+}
diff --git a/zellij-utils/Cargo.toml b/zellij-utils/Cargo.toml
index 24f0e8c2..4a543e08 100644
--- a/zellij-utils/Cargo.toml
+++ b/zellij-utils/Cargo.toml
@@ -41,6 +41,7 @@ sha2 = { workspace = true }
libc = { workspace = true }
[target.'cfg(not(target_family = "wasm"))'.dependencies]
+base64 = { version = "0.13.0", default-features = false, features = ["std"] }
tokio = { workspace = true }
tokio-stream = { version = "0.1" }
tokio-util = { version = "0.7", default-features = false, features = ["compat", "io"] }
diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl
index 22ae3699..a551dc80 100644
--- a/zellij-utils/assets/config/default.kdl
+++ b/zellij-utils/assets/config/default.kdl
@@ -33,6 +33,7 @@ keybinds {
bind "s" { NewPane "stacked"; SwitchToMode "Normal"; }
bind "x" { CloseFocus; SwitchToMode "Normal"; }
bind "f" { ToggleFocusFullscreen; SwitchToMode "Normal"; }
+ bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "Normal"; }
bind "z" { TogglePaneFrames; SwitchToMode "Normal"; }
bind "w" { ToggleFloatingPanes; SwitchToMode "Normal"; }
bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "Normal"; }
@@ -115,6 +116,9 @@ keybinds {
bind "Ctrl o" { SwitchToMode "Normal"; }
bind "Ctrl s" { SwitchToMode "Scroll"; }
bind "d" { Detach; }
+ bind "]" { FocusHostSession; SwitchToMode "Normal"; }
+ bind "[" { FocusGuestSession; SwitchToMode "Normal"; }
+ bind "f" { ToggleHostFullscreen; SwitchToMode "Normal"; }
bind "w" {
LaunchOrFocusPlugin "session-manager" {
floating true
@@ -261,6 +265,15 @@ load_plugins {
//
// on_force_close "quit"
+// Choose how to handle a nested Zellij session detected running inside a pane
+// Options:
+// - ask (Default - prompt with a modal to zoom in or control on focus)
+// - fullscreen (always zoom into the nested session)
+// - descend (always control the nested session on focus)
+// - never (never prompt or descend; do it manually)
+//
+// nested_session_handling "ask"
+
// Send a request for a simplified ui (without arrow fonts) to plugins
// Options:
// - true
diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm
index 5f8c923e..927632bc 100755
--- a/zellij-utils/assets/plugins/about.wasm
+++ b/zellij-utils/assets/plugins/about.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm
index 0f55fac9..bf73dadd 100755
--- a/zellij-utils/assets/plugins/compact-bar.wasm
+++ b/zellij-utils/assets/plugins/compact-bar.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm
index dcd848fa..d615cdca 100755
--- a/zellij-utils/assets/plugins/configuration.wasm
+++ b/zellij-utils/assets/plugins/configuration.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm
index ccde7690..1adf8d94 100755
--- a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm
+++ b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm
index c5382f72..fece91ea 100755
--- a/zellij-utils/assets/plugins/layout-manager.wasm
+++ b/zellij-utils/assets/plugins/layout-manager.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/link.wasm b/zellij-utils/assets/plugins/link.wasm
index 32d2aba3..18a96f47 100755
--- a/zellij-utils/assets/plugins/link.wasm
+++ b/zellij-utils/assets/plugins/link.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/mobile.wasm b/zellij-utils/assets/plugins/mobile.wasm
index 05bd6516..00ec86de 100755
--- a/zellij-utils/assets/plugins/mobile.wasm
+++ b/zellij-utils/assets/plugins/mobile.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm
index 036a726e..2f2767d5 100755
--- a/zellij-utils/assets/plugins/multiple-select.wasm
+++ b/zellij-utils/assets/plugins/multiple-select.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm
index 2d4caf64..e146e20e 100755
--- a/zellij-utils/assets/plugins/plugin-manager.wasm
+++ b/zellij-utils/assets/plugins/plugin-manager.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm
index 61e16dd8..bd2ba4b0 100755
--- a/zellij-utils/assets/plugins/session-manager.wasm
+++ b/zellij-utils/assets/plugins/session-manager.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm
index fa0fb2e7..9fe98cb2 100755
--- a/zellij-utils/assets/plugins/share.wasm
+++ b/zellij-utils/assets/plugins/share.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm
index 70a76358..d2f14b1b 100755
--- a/zellij-utils/assets/plugins/status-bar.wasm
+++ b/zellij-utils/assets/plugins/status-bar.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm
index 9197346b..f348f0b8 100755
--- a/zellij-utils/assets/plugins/strider.wasm
+++ b/zellij-utils/assets/plugins/strider.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm
index f52aa749..0bce4ec4 100755
--- a/zellij-utils/assets/plugins/tab-bar.wasm
+++ b/zellij-utils/assets/plugins/tab-bar.wasm
Binary files differ
diff --git a/zellij-utils/assets/prost/api.action.rs b/zellij-utils/assets/prost/api.action.rs
index a703c9f0..4860feb1 100644
--- a/zellij-utils/assets/prost/api.action.rs
+++ b/zellij-utils/assets/prost/api.action.rs
@@ -1134,6 +1134,10 @@ pub enum ActionName {
ToggleTheme = 103,
FocusLastPane = 104,
SetPaneFrameStyle = 105,
+ ToggleFocusNoUiFullscreen = 106,
+ FocusHostSession = 107,
+ ToggleHostFullscreen = 108,
+ FocusGuestSession = 109,
}
impl ActionName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -1245,6 +1249,10 @@ impl ActionName {
ActionName::ToggleTheme => "ToggleTheme",
ActionName::FocusLastPane => "FocusLastPane",
ActionName::SetPaneFrameStyle => "SetPaneFrameStyle",
+ ActionName::ToggleFocusNoUiFullscreen => "ToggleFocusNoUiFullscreen",
+ ActionName::FocusHostSession => "FocusHostSession",
+ ActionName::ToggleHostFullscreen => "ToggleHostFullscreen",
+ ActionName::FocusGuestSession => "FocusGuestSession",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -1353,6 +1361,10 @@ impl ActionName {
"ToggleTheme" => Some(Self::ToggleTheme),
"FocusLastPane" => Some(Self::FocusLastPane),
"SetPaneFrameStyle" => Some(Self::SetPaneFrameStyle),
+ "ToggleFocusNoUiFullscreen" => Some(Self::ToggleFocusNoUiFullscreen),
+ "FocusHostSession" => Some(Self::FocusHostSession),
+ "ToggleHostFullscreen" => Some(Self::ToggleHostFullscreen),
+ "FocusGuestSession" => Some(Self::FocusGuestSession),
_ => None,
}
}
diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs
index 8f050da7..4230d347 100644
--- a/zellij-utils/assets/prost/api.event.rs
+++ b/zellij-utils/assets/prost/api.event.rs
@@ -703,6 +703,18 @@ pub struct ModeUpdatePayload {
pub web_server_capability: ::core::option::Option<bool>,
#[prost(enumeration="PaneFrameStyle", optional, tag="16")]
pub pane_frame_style: ::core::option::Option<i32>,
+ #[prost(bool, optional, tag="17")]
+ pub session_dimmed: ::core::option::Option<bool>,
+ #[prost(string, repeated, tag="18")]
+ pub ancestry: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(bool, optional, tag="19")]
+ pub host_fullscreen: ::core::option::Option<bool>,
+ #[prost(string, repeated, tag="20")]
+ pub nested_ascend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(bool, optional, tag="21")]
+ pub session_ascended: ::core::option::Option<bool>,
+ #[prost(string, repeated, tag="22")]
+ pub nested_descend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs
index 676361b2..9f053ae3 100644
--- a/zellij-utils/assets/prost/api.plugin_command.rs
+++ b/zellij-utils/assets/prost/api.plugin_command.rs
@@ -2249,6 +2249,8 @@ pub enum CommandName {
SetPaneFrameStyle = 224,
ToggleFloatingPanes = 225,
NewPane = 226,
+ ToggleFocusNoUiFullscreen = 227,
+ FocusHostSession = 228,
}
impl CommandName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -2461,6 +2463,8 @@ impl CommandName {
CommandName::SetPaneFrameStyle => "SetPaneFrameStyle",
CommandName::ToggleFloatingPanes => "ToggleFloatingPanes",
CommandName::NewPane => "NewPane",
+ CommandName::ToggleFocusNoUiFullscreen => "ToggleFocusNoUiFullscreen",
+ CommandName::FocusHostSession => "FocusHostSession",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -2670,6 +2674,8 @@ impl CommandName {
"SetPaneFrameStyle" => Some(Self::SetPaneFrameStyle),
"ToggleFloatingPanes" => Some(Self::ToggleFloatingPanes),
"NewPane" => Some(Self::NewPane),
+ "ToggleFocusNoUiFullscreen" => Some(Self::ToggleFocusNoUiFullscreen),
+ "FocusHostSession" => Some(Self::FocusHostSession),
_ => None,
}
}
diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs
index 7cb4a620..11cc7937 100644
--- a/zellij-utils/assets/prost_ipc/client_server_contract.rs
+++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs
@@ -119,7 +119,7 @@ pub struct RgbColor {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Action {
- #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142")]
+ #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147")]
pub action_type: ::core::option::Option<action::ActionType>,
}
/// Nested message and enum types in `Action`.
@@ -413,6 +413,16 @@ pub mod action {
FocusLastPane(super::FocusLastPaneAction),
#[prost(message, tag="142")]
SetPaneFrameStyle(super::SetPaneFrameStyleAction),
+ #[prost(message, tag="143")]
+ ToggleFocusNoUiFullscreen(super::ToggleFocusNoUiFullscreenAction),
+ #[prost(message, tag="144")]
+ ToggleNoUiFullscreenByPaneId(super::ToggleNoUiFullscreenByPaneIdAction),
+ #[prost(message, tag="145")]
+ FocusHostSession(super::FocusHostSessionAction),
+ #[prost(message, tag="146")]
+ ToggleHostFullscreen(super::ToggleHostFullscreenAction),
+ #[prost(message, tag="147")]
+ FocusGuestSession(super::FocusGuestSessionAction),
}
}
// Action message definitions (all 92 variants)
@@ -436,6 +446,18 @@ pub struct FocusLastPaneAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct FocusHostSessionAction {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct FocusGuestSessionAction {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ToggleHostFullscreenAction {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SwitchFocusAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -498,6 +520,10 @@ pub struct ToggleFocusFullscreenAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ToggleFocusNoUiFullscreenAction {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TogglePaneFramesAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -1967,7 +1993,7 @@ pub struct Options {
pub show_release_notes: ::core::option::Option<bool>,
#[prost(bool, optional, tag="33")]
pub advanced_mouse_actions: ::core::option::Option<bool>,
- #[prost(bool, optional, tag="53")]
+ #[prost(bool, optional, tag="54")]
pub mouse_scroll_resize: ::core::option::Option<bool>,
#[prost(string, optional, tag="34")]
pub web_server_ip: ::core::option::Option<::prost::alloc::string::String>,
@@ -2007,6 +2033,8 @@ pub struct Options {
pub pane_frame_style: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, optional, tag="52")]
pub stacked_pane_list: ::core::option::Option<bool>,
+ #[prost(enumeration="NestedSessionHandling", optional, tag="53")]
+ pub nested_session_handling: ::core::option::Option<i32>,
}
/// Pane-targeting action messages
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -2101,6 +2129,12 @@ pub struct ToggleFullscreenByPaneIdAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ToggleNoUiFullscreenByPaneIdAction {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TogglePaneEmbedOrFloatingByPaneIdAction {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
@@ -2966,10 +3000,45 @@ impl MobileLayout {
}
}
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+#[repr(i32)]
+pub enum NestedSessionHandling {
+ Unspecified = 0,
+ Ask = 1,
+ Fullscreen = 2,
+ Descend = 3,
+ Never = 4,
+}
+impl NestedSessionHandling {
+ /// String value of the enum field names used in the ProtoBuf definition.
+ ///
+ /// The values are not transformed in any way and thus are considered stable
+ /// (if the ProtoBuf definition does not change) and safe for programmatic use.
+ pub fn as_str_name(&self) -> &'static str {
+ match self {
+ NestedSessionHandling::Unspecified => "NESTED_SESSION_HANDLING_UNSPECIFIED",
+ NestedSessionHandling::Ask => "NESTED_SESSION_HANDLING_ASK",
+ NestedSessionHandling::Fullscreen => "NESTED_SESSION_HANDLING_FULLSCREEN",
+ NestedSessionHandling::Descend => "NESTED_SESSION_HANDLING_DESCEND",
+ NestedSessionHandling::Never => "NESTED_SESSION_HANDLING_NEVER",
+ }
+ }
+ /// Creates an enum from field names used in the ProtoBuf definition.
+ pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
+ match value {
+ "NESTED_SESSION_HANDLING_UNSPECIFIED" => Some(Self::Unspecified),
+ "NESTED_SESSION_HANDLING_ASK" => Some(Self::Ask),
+ "NESTED_SESSION_HANDLING_FULLSCREEN" => Some(Self::Fullscreen),
+ "NESTED_SESSION_HANDLING_DESCEND" => Some(Self::Descend),
+ "NESTED_SESSION_HANDLING_NEVER" => Some(Self::Never),
+ _ => None,
+ }
+ }
+}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ClientToServerMsg {
- #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21")]
+ #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22")]
pub message: ::core::option::Option<client_to_server_msg::Message>,
}
/// Nested message and enum types in `ClientToServerMsg`.
@@ -3019,6 +3088,8 @@ pub mod client_to_server_msg {
HostTerminalThemeChanged(super::HostTerminalThemeChangedMsg),
#[prost(message, tag="21")]
SoftKeyboardVisibilityChanged(super::SoftKeyboardVisibilityChangedMsg),
+ #[prost(message, tag="22")]
+ NestedSessionFrameFromHost(super::NestedSessionFrameFromHostMsg),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -3172,6 +3243,12 @@ pub struct SoftKeyboardVisibilityChangedMsg {
#[prost(bool, tag="1")]
pub visible: bool,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct NestedSessionFrameFromHostMsg {
+ #[prost(bytes="vec", tag="1")]
+ pub payload_bytes: ::prost::alloc::vec::Vec<u8>,
+}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum ResizeCause {
@@ -3230,7 +3307,7 @@ impl HostTerminalThemeIndication {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ServerToClientMsg {
- #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17")]
+ #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18")]
pub message: ::core::option::Option<server_to_client_msg::Message>,
}
/// Nested message and enum types in `ServerToClientMsg`.
@@ -3272,6 +3349,8 @@ pub mod server_to_client_msg {
ForwardQueryToHost(super::ForwardQueryToHostMsg),
#[prost(message, tag="17")]
SetSoftKeyboard(super::SetSoftKeyboardMsg),
+ #[prost(message, tag="18")]
+ EmitNestedSessionFrame(super::EmitNestedSessionFrameMsg),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -3385,3 +3464,9 @@ pub struct SetSoftKeyboardMsg {
#[prost(bool, tag="1")]
pub on: bool,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct EmitNestedSessionFrameMsg {
+ #[prost(bytes="vec", tag="1")]
+ pub payload_bytes: ::prost::alloc::vec::Vec<u8>,
+}
diff --git a/zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs b/zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs
new file mode 100644
index 00000000..59f16320
--- /dev/null
+++ b/zellij-utils/assets/prost_nested_session/generated_nested_session_api.rs
@@ -0,0 +1,3 @@
+pub mod nested_session_contract {
+ include!("nested_session_contract.rs");
+}
diff --git a/zellij-utils/assets/prost_nested_session/nested_session_contract.rs b/zellij-utils/assets/prost_nested_session/nested_session_contract.rs
new file mode 100644
index 00000000..1a9e117a
--- /dev/null
+++ b/zellij-utils/assets/prost_nested_session/nested_session_contract.rs
@@ -0,0 +1,170 @@
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct NestedSessionMessage {
+ #[prost(oneof="nested_session_message::Payload", tags="1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14")]
+ pub payload: ::core::option::Option<nested_session_message::Payload>,
+}
+/// Nested message and enum types in `NestedSessionMessage`.
+pub mod nested_session_message {
+ #[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Payload {
+ #[prost(message, tag="1")]
+ Announce(super::Announce),
+ #[prost(message, tag="2")]
+ FocusHost(super::FocusHost),
+ #[prost(message, tag="3")]
+ HostFullscreen(super::ToggleHostFullscreen),
+ #[prost(message, tag="4")]
+ Pong(super::Pong),
+ #[prost(message, tag="5")]
+ Bye(super::Bye),
+ #[prost(message, tag="8")]
+ AnnounceAck(super::AnnounceAck),
+ #[prost(message, tag="9")]
+ FocusGained(super::FocusGained),
+ #[prost(message, tag="10")]
+ FocusLost(super::FocusLost),
+ #[prost(message, tag="11")]
+ FullscreenState(super::FullscreenState),
+ #[prost(message, tag="12")]
+ AncestryUpdate(super::AncestryUpdate),
+ #[prost(message, tag="13")]
+ Ping(super::Ping),
+ #[prost(message, tag="14")]
+ ShortcutUpdate(super::ShortcutUpdate),
+ }
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Announce {
+ #[prost(string, tag="1")]
+ pub session_name: ::prost::alloc::string::String,
+ #[prost(enumeration="NestedCapability", repeated, tag="2")]
+ pub capabilities: ::prost::alloc::vec::Vec<i32>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct FocusHost {
+ #[prost(enumeration="NestedDirection", tag="1")]
+ pub direction: i32,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ToggleHostFullscreen {
+ #[prost(bool, tag="1")]
+ pub fullscreen: bool,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Pong {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Bye {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AnnounceAck {
+ #[prost(string, repeated, tag="1")]
+ pub ancestry: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(enumeration="NestedCapability", repeated, tag="2")]
+ pub capabilities: ::prost::alloc::vec::Vec<i32>,
+ #[prost(string, repeated, tag="3")]
+ pub descend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ShortcutUpdate {
+ #[prost(string, repeated, tag="1")]
+ pub ascend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(string, repeated, tag="2")]
+ pub descend_keys: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct FocusGained {
+ #[prost(enumeration="NestedDirection", tag="1")]
+ pub from_direction: i32,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct FocusLost {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct FullscreenState {
+ #[prost(bool, tag="1")]
+ pub fullscreen: bool,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AncestryUpdate {
+ #[prost(string, repeated, tag="1")]
+ pub ancestry: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Ping {
+}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+#[repr(i32)]
+pub enum NestedCapability {
+ Unspecified = 0,
+ NestedControl = 1,
+}
+impl NestedCapability {
+ /// String value of the enum field names used in the ProtoBuf definition.
+ ///
+ /// The values are not transformed in any way and thus are considered stable
+ /// (if the ProtoBuf definition does not change) and safe for programmatic use.
+ pub fn as_str_name(&self) -> &'static str {
+ match self {
+ NestedCapability::Unspecified => "NESTED_CAPABILITY_UNSPECIFIED",
+ NestedCapability::NestedControl => "NESTED_CAPABILITY_NESTED_CONTROL",
+ }
+ }
+ /// Creates an enum from field names used in the ProtoBuf definition.
+ pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
+ match value {
+ "NESTED_CAPABILITY_UNSPECIFIED" => Some(Self::Unspecified),
+ "NESTED_CAPABILITY_NESTED_CONTROL" => Some(Self::NestedControl),
+ _ => None,
+ }
+ }
+}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+#[repr(i32)]
+pub enum NestedDirection {
+ Unspecified = 0,
+ Left = 1,
+ Right = 2,
+ Up = 3,
+ Down = 4,
+}
+impl NestedDirection {
+ /// String value of the enum field names used in the ProtoBuf definition.
+ ///
+ /// The values are not transformed in any way and thus are considered stable
+ /// (if the ProtoBuf definition does not change) and safe for programmatic use.
+ pub fn as_str_name(&self) -> &'static str {
+ match self {
+ NestedDirection::Unspecified => "NESTED_DIRECTION_UNSPECIFIED",
+ NestedDirection::Left => "NESTED_DIRECTION_LEFT",
+ NestedDirection::Right => "NESTED_DIRECTION_RIGHT",
+ NestedDirection::Up => "NESTED_DIRECTION_UP",
+ NestedDirection::Down => "NESTED_DIRECTION_DOWN",
+ }
+ }
+ /// Creates an enum from field names used in the ProtoBuf definition.
+ pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
+ match value {
+ "NESTED_DIRECTION_UNSPECIFIED" => Some(Self::Unspecified),
+ "NESTED_DIRECTION_LEFT" => Some(Self::Left),
+ "NESTED_DIRECTION_RIGHT" => Some(Self::Right),
+ "NESTED_DIRECTION_UP" => Some(Self::Up),
+ "NESTED_DIRECTION_DOWN" => Some(Self::Down),
+ _ => None,
+ }
+ }
+}
diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs
index e09755e0..6d7f0bc2 100644
--- a/zellij-utils/src/cli.rs
+++ b/zellij-utils/src/cli.rs
@@ -900,6 +900,18 @@ pub enum CliAction {
#[clap(short, long, value_parser)]
pane_id: Option<String>,
},
+ #[clap(
+ about = "Toggle between fullscreen over the entire display (including the UI bars) and normal layout"
+ )]
+ ToggleNoUiFullscreen {
+ #[clap(
+ short,
+ long,
+ value_parser,
+ help = "Target a specific pane by ID (eg. terminal_1, plugin_2, or 3)"
+ )]
+ pane_id: Option<String>,
+ },
/// Toggle frames around panes in the UI
TogglePaneFrames,
SetPaneFrameStyle {
diff --git a/zellij-utils/src/client_server_contract/client_to_server.proto b/zellij-utils/src/client_server_contract/client_to_server.proto
index bc7c0c06..d3b59483 100644
--- a/zellij-utils/src/client_server_contract/client_to_server.proto
+++ b/zellij-utils/src/client_server_contract/client_to_server.proto
@@ -26,6 +26,7 @@ message ClientToServerMsg {
ForwardedReplyFromHostMsg forwarded_reply_from_host = 19;
HostTerminalThemeChangedMsg host_terminal_theme_changed = 20;
SoftKeyboardVisibilityChangedMsg soft_keyboard_visibility_changed = 21;
+ NestedSessionFrameFromHostMsg nested_session_frame_from_host = 22;
}
}
@@ -137,3 +138,7 @@ enum HostTerminalThemeIndication {
message SoftKeyboardVisibilityChangedMsg {
bool visible = 1;
}
+
+message NestedSessionFrameFromHostMsg {
+ bytes payload_bytes = 1;
+}
diff --git a/zellij-utils/src/client_server_contract/common_types.proto b/zellij-utils/src/client_server_contract/common_types.proto
index a6debf92..1f1da83f 100644
--- a/zellij-utils/src/client_server_contract/common_types.proto
+++ b/zellij-utils/src/client_server_contract/common_types.proto
@@ -280,6 +280,11 @@ message Action {
ToggleMobileModeAction toggle_mobile_mode = 140;
FocusLastPaneAction focus_last_pane = 141;
SetPaneFrameStyleAction set_pane_frame_style = 142;
+ ToggleFocusNoUiFullscreenAction toggle_focus_no_ui_fullscreen = 143;
+ ToggleNoUiFullscreenByPaneIdAction toggle_no_ui_fullscreen_by_pane_id = 144;
+ FocusHostSessionAction focus_host_session = 145;
+ ToggleHostFullscreenAction toggle_host_fullscreen = 146;
+ FocusGuestSessionAction focus_guest_session = 147;
}
}
@@ -290,6 +295,9 @@ message QuitAction {}
message FocusNextPaneAction {}
message FocusPreviousPaneAction {}
message FocusLastPaneAction {}
+message FocusHostSessionAction {}
+message FocusGuestSessionAction {}
+message ToggleHostFullscreenAction {}
message SwitchFocusAction {}
message MovePaneBackwardsAction {}
message ClearScreenAction {}
@@ -307,6 +315,7 @@ message PageScrollDownAction {}
message HalfPageScrollUpAction {}
message HalfPageScrollDownAction {}
message ToggleFocusFullscreenAction {}
+message ToggleFocusNoUiFullscreenAction {}
message TogglePaneFramesAction {}
message SetPaneFrameStyleAction {
string style = 1;
@@ -1181,7 +1190,6 @@ message Options {
optional bool show_startup_tips = 31;
optional bool show_release_notes = 32;
optional bool advanced_mouse_actions = 33;
- optional bool mouse_scroll_resize = 53;
optional string web_server_ip = 34;
optional uint32 web_server_port = 35;
optional string web_server_cert = 36;
@@ -1201,6 +1209,8 @@ message Options {
optional uint32 mobile_threshold_rows = 50;
optional string pane_frame_style = 51;
optional bool stacked_pane_list = 52;
+ optional NestedSessionHandling nested_session_handling = 53;
+ optional bool mouse_scroll_resize = 54;
}
enum OnForceClose {
@@ -1235,6 +1245,14 @@ enum MobileLayout {
MOBILE_LAYOUT_NEVER = 3;
}
+enum NestedSessionHandling {
+ NESTED_SESSION_HANDLING_UNSPECIFIED = 0;
+ NESTED_SESSION_HANDLING_ASK = 1;
+ NESTED_SESSION_HANDLING_FULLSCREEN = 2;
+ NESTED_SESSION_HANDLING_DESCEND = 3;
+ NESTED_SESSION_HANDLING_NEVER = 4;
+}
+
// Pane-targeting action messages
message ScrollUpByPaneIdAction { PaneId pane_id = 1; }
message ScrollDownByPaneIdAction { PaneId pane_id = 1; }
@@ -1253,6 +1271,7 @@ message EditScrollbackByPaneIdAction {
bool ansi = 2;
}
message ToggleFullscreenByPaneIdAction { PaneId pane_id = 1; }
+message ToggleNoUiFullscreenByPaneIdAction { PaneId pane_id = 1; }
message TogglePaneEmbedOrFloatingByPaneIdAction { PaneId pane_id = 1; }
message CloseFocusByPaneIdAction { PaneId pane_id = 1; }
message RenamePaneByPaneIdAction { PaneId pane_id = 1; bytes name = 2; }
diff --git a/zellij-utils/src/client_server_contract/server_to_client.proto b/zellij-utils/src/client_server_contract/server_to_client.proto
index a8c4779d..0c7e9aec 100644
--- a/zellij-utils/src/client_server_contract/server_to_client.proto
+++ b/zellij-utils/src/client_server_contract/server_to_client.proto
@@ -22,6 +22,7 @@ message ServerToClientMsg {
SubscribedPaneClosedMsg subscribed_pane_closed = 15;
ForwardQueryToHostMsg forward_query_to_host = 16;
SetSoftKeyboardMsg set_soft_keyboard = 17;
+ EmitNestedSessionFrameMsg emit_nested_session_frame = 18;
}
}
@@ -99,3 +100,7 @@ message ForwardQueryToHostMsg {
message SetSoftKeyboardMsg {
bool on = 1;
}
+
+message EmitNestedSessionFrameMsg {
+ bytes payload_bytes = 1;
+}
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index 758e2ab3..62f95a06 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -254,6 +254,7 @@ impl FromStr for BareKey {
"end" => Ok(BareKey::End),
"backspace" => Ok(BareKey::Backspace),
"delete" => Ok(BareKey::Delete),
+ "del" => Ok(BareKey::Delete),
"insert" => Ok(BareKey::Insert),
"f1" => Ok(BareKey::F(1)),
"f2" => Ok(BareKey::F(2)),
@@ -1751,6 +1752,12 @@ pub struct ModeInfo {
pub web_server_port: Option<u16>,
pub web_server_capability: Option<bool>,
pub pane_frame_style: Option<PaneFrameStyle>,
+ pub session_dimmed: Option<bool>,
+ pub session_ancestry: Vec<String>,
+ pub host_fullscreen: Option<bool>,
+ pub nested_ascend_keys: Vec<KeyWithModifier>,
+ pub session_ascended: Option<bool>,
+ pub nested_descend_keys: Vec<KeyWithModifier>,
}
impl ModeInfo {
@@ -3416,6 +3423,7 @@ pub enum PluginCommand {
PageScrollUp,
PageScrollDown,
ToggleFocusFullscreen,
+ ToggleFocusNoUiFullscreen,
TogglePaneFrames,
SetPaneFrameStyle(PaneFrameStyle),
TogglePaneEmbedOrEject,
@@ -3645,6 +3653,7 @@ pub enum PluginCommand {
},
SetShadowFocus(PaneId),
ExitMobileMode,
+ FocusHostSession,
}
// Response type for plugin API methods that open a pane in a new tab
diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs
index 8ffe0907..ae2cc239 100644
--- a/zellij-utils/src/errors.rs
+++ b/zellij-utils/src/errors.rs
@@ -281,6 +281,7 @@ pub enum ScreenContext {
CloseFocusedPane,
ToggleActiveSyncTab,
ToggleActiveTerminalFullscreen,
+ ToggleActiveTerminalNoUiFullscreen,
TogglePaneFrames,
SetPaneFrameStyle,
SetSelectable,
@@ -314,6 +315,10 @@ pub enum ScreenContext {
TerminalForegroundColor,
TerminalColorRegisters,
ForwardHostQuery,
+ NestedSessionMessageFromPane,
+ NestedGuestPingTick,
+ NestedSessionMessageFromHost,
+ GuestModalChoice,
ForwardedReplyFromHost,
ResumePaneAfterForward,
HostTerminalThemeChanged,
@@ -453,6 +458,7 @@ pub enum ScreenContext {
ClearScreenWithPaneId,
EditScrollbackWithPaneId,
ToggleFullscreenWithPaneId,
+ ToggleNoUiFullscreenWithPaneId,
TogglePaneEmbedOrFloatingWithPaneId,
CloseFocusWithPaneId,
RenamePaneWithPaneId,
@@ -475,6 +481,9 @@ pub enum ScreenContext {
ReevaluateMobileMode,
SetSoftKeyboard,
SetShadowFocus,
+ FocusHostSession,
+ FocusGuestSession,
+ ToggleHostFullscreen,
}
/// Stack call representations corresponding to the different types of [`PtyInstruction`]s.
@@ -586,6 +595,7 @@ pub enum ClientContext {
RenamedSession,
ConfigFileUpdated,
ForwardQueryToHost,
+ EmitNestedSessionFrame,
}
/// Stack call representations corresponding to the different types of [`ServerInstruction`]s.
@@ -622,6 +632,8 @@ pub enum ServerContext {
SendWebClientsForbidden,
ClearMouseHelpText,
ForwardQueryToHost,
+ KeyPassthroughChanged,
+ EmitNestedSessionFrameToClient,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -653,6 +665,8 @@ pub enum BackgroundJobContext {
FlashTabBell,
StopFlashTabBell,
MobileGateTimeout,
+ StartNestedGuestPing,
+ StopNestedGuestPing,
Exit,
}
diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs
index c0e953b6..95409609 100644
--- a/zellij-utils/src/input/actions.rs
+++ b/zellij-utils/src/input/actions.rs
@@ -216,6 +216,7 @@ pub enum Action {
HalfPageScrollDown,
/// Toggle between fullscreen focus pane and normal layout.
ToggleFocusFullscreen,
+ ToggleFocusNoUiFullscreen,
/// Toggle frames around panes in the UI
TogglePaneFrames,
SetPaneFrameStyle(PaneFrameStyle),
@@ -508,6 +509,9 @@ pub enum Action {
BreakPane,
BreakPaneRight,
BreakPaneLeft,
+ FocusHostSession,
+ FocusGuestSession,
+ ToggleHostFullscreen,
RenameSession {
name: String,
},
@@ -623,6 +627,9 @@ pub enum Action {
ToggleFocusFullscreenByPaneId {
pane_id: PaneId,
},
+ ToggleFocusNoUiFullscreenByPaneId {
+ pane_id: PaneId,
+ },
TogglePaneEmbedOrFloatingByPaneId {
pane_id: PaneId,
},
@@ -1018,6 +1025,16 @@ impl Action {
},
None => Ok(vec![Action::ToggleFocusFullscreen]),
},
+ CliAction::ToggleNoUiFullscreen { pane_id } => match pane_id {
+ Some(pane_id_str) => {
+ let pane_id = PaneId::from_str(&pane_id_str)
+ .map_err(|_| format!(
+ "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)"
+ ))?;
+ Ok(vec![Action::ToggleFocusNoUiFullscreenByPaneId { pane_id }])
+ },
+ None => Ok(vec![Action::ToggleFocusNoUiFullscreen]),
+ },
CliAction::TogglePaneFrames => Ok(vec![Action::TogglePaneFrames]),
CliAction::SetPaneFrameStyle { style } => Ok(vec![Action::SetPaneFrameStyle(style)]),
CliAction::ToggleActiveSyncTab { tab_id } => match tab_id {
@@ -2883,6 +2900,33 @@ mod tests {
assert!(matches!(actions[0], Action::ToggleFocusFullscreen));
}
+ #[test]
+ fn test_toggle_no_ui_fullscreen_with_pane_id() {
+ let cli_action = CliAction::ToggleNoUiFullscreen {
+ pane_id: Some("terminal_16".to_string()),
+ };
+ let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None);
+ assert!(result.is_ok());
+ let actions = result.unwrap();
+ assert_eq!(actions.len(), 1);
+ match &actions[0] {
+ Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => {
+ assert!(matches!(pane_id, PaneId::Terminal(16)));
+ },
+ _ => panic!("Expected ToggleFocusNoUiFullscreenByPaneId action"),
+ }
+ }
+
+ #[test]
+ fn test_toggle_no_ui_fullscreen_without_pane_id() {
+ let cli_action = CliAction::ToggleNoUiFullscreen { pane_id: None };
+ let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None);
+ assert!(result.is_ok());
+ let actions = result.unwrap();
+ assert_eq!(actions.len(), 1);
+ assert!(matches!(actions[0], Action::ToggleFocusNoUiFullscreen));
+ }
+
// 15. TogglePaneEmbedOrFloating
#[test]
fn test_toggle_pane_embed_or_floating_with_pane_id() {
diff --git a/zellij-utils/src/input/keybinds.rs b/zellij-utils/src/input/keybinds.rs
index c3943182..2cbdeee7 100644
--- a/zellij-utils/src/input/keybinds.rs
+++ b/zellij-utils/src/input/keybinds.rs
@@ -116,6 +116,60 @@ impl Keybinds {
}
}
+const MAX_SHORTCUT_SEARCH_DEPTH: usize = 4;
+
+pub fn shortcut_for_action(
+ keybinds: &KeybindsVec,
+ base_mode: InputMode,
+ is_target_action: impl Fn(&Action) -> bool,
+) -> Option<Vec<KeyWithModifier>> {
+ let mode_binds = |mode: InputMode| -> Vec<(KeyWithModifier, Vec<Action>)> {
+ let mut binds = keybinds
+ .iter()
+ .find(|(bind_mode, _)| *bind_mode == mode)
+ .map(|(_, binds)| binds.clone())
+ .unwrap_or_default();
+ binds.sort_by(|(a, _), (b, _)| a.cmp(b));
+ binds
+ };
+ let mut visited: Vec<InputMode> = vec![base_mode];
+ let mut queue: Vec<(InputMode, Vec<KeyWithModifier>)> = vec![(base_mode, vec![])];
+ for _ in 0..MAX_SHORTCUT_SEARCH_DEPTH {
+ let mut next_queue: Vec<(InputMode, Vec<KeyWithModifier>)> = vec![];
+ for (mode, path) in &queue {
+ for (key, actions) in mode_binds(*mode) {
+ if actions.iter().any(&is_target_action) {
+ let mut shortcut = path.clone();
+ shortcut.push(key);
+ return Some(shortcut);
+ }
+ }
+ }
+ for (mode, path) in &queue {
+ for (key, actions) in mode_binds(*mode) {
+ for action in &actions {
+ if let Action::SwitchToMode {
+ input_mode: next_mode,
+ } = action
+ {
+ if !visited.contains(next_mode) {
+ visited.push(*next_mode);
+ let mut next_path = path.clone();
+ next_path.push(key.clone());
+ next_queue.push((*next_mode, next_path));
+ }
+ }
+ }
+ }
+ }
+ if next_queue.is_empty() {
+ return None;
+ }
+ queue = next_queue;
+ }
+ None
+}
+
// we need to do this because [10] in standard STDIN, [10] is both Enter (without a carriage
// return) and ctrl-j - so here, if ctrl-j is bound we return its bound action, and otherwise we
// just write the raw bytes to the terminal and let whichever program is there decide what they are
diff --git a/zellij-utils/src/input/mod.rs b/zellij-utils/src/input/mod.rs
index 0934287e..3ca7508f 100644
--- a/zellij-utils/src/input/mod.rs
+++ b/zellij-utils/src/input/mod.rs
@@ -55,6 +55,12 @@ mod not_wasm {
web_server_port: None,
web_server_capability: None,
pane_frame_style: None,
+ session_dimmed: None,
+ session_ancestry: vec![],
+ host_fullscreen: None,
+ nested_ascend_keys: vec![],
+ session_ascended: None,
+ nested_descend_keys: vec![],
}
}
diff --git a/zellij-utils/src/input/options.rs b/zellij-utils/src/input/options.rs
index da5f0248..194ab24c 100644
--- a/zellij-utils/src/input/options.rs
+++ b/zellij-utils/src/input/options.rs
@@ -75,6 +75,37 @@ impl MobileLayoutConfiguration {
}
}
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ArgEnum)]
+pub enum NestedSessionHandling {
+ #[serde(alias = "ask")]
+ Ask,
+ #[serde(alias = "fullscreen")]
+ Fullscreen,
+ #[serde(alias = "descend")]
+ Descend,
+ #[serde(alias = "never")]
+ Never,
+}
+
+impl Default for NestedSessionHandling {
+ fn default() -> Self {
+ Self::Ask
+ }
+}
+
+impl FromStr for NestedSessionHandling {
+ type Err = String;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "Ask" | "ask" => Ok(Self::Ask),
+ "Fullscreen" | "fullscreen" => Ok(Self::Fullscreen),
+ "Descend" | "descend" => Ok(Self::Descend),
+ "Never" | "never" => Ok(Self::Never),
+ _ => Err(format!("No such nested_session_handling: {}", s)),
+ }
+ }
+}
+
impl Default for OnForceClose {
fn default() -> Self {
Self::Detach
@@ -415,6 +446,12 @@ pub struct Options {
#[clap(long, value_parser)]
#[serde(default)]
pub mobile_threshold_rows: Option<u16>,
+
+ /// How to handle a nested Zellij session detected inside a pane
+ /// (ask, fullscreen, descend, never)
+ #[clap(long, arg_enum, hide_possible_values = true, value_parser)]
+ #[serde(default)]
+ pub nested_session_handling: Option<NestedSessionHandling>,
}
#[derive(ArgEnum, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
@@ -527,6 +564,9 @@ impl Options {
let mobile_layout = other.mobile_layout.or(self.mobile_layout);
let mobile_threshold_cols = other.mobile_threshold_cols.or(self.mobile_threshold_cols);
let mobile_threshold_rows = other.mobile_threshold_rows.or(self.mobile_threshold_rows);
+ let nested_session_handling = other
+ .nested_session_handling
+ .or(self.nested_session_handling);
Options {
simplified_ui,
@@ -582,6 +622,7 @@ impl Options {
mobile_layout,
mobile_threshold_cols,
mobile_threshold_rows,
+ nested_session_handling,
}
}
@@ -674,6 +715,9 @@ impl Options {
let mobile_layout = other.mobile_layout.or(self.mobile_layout);
let mobile_threshold_cols = other.mobile_threshold_cols.or(self.mobile_threshold_cols);
let mobile_threshold_rows = other.mobile_threshold_rows.or(self.mobile_threshold_rows);
+ let nested_session_handling = other
+ .nested_session_handling
+ .or(self.nested_session_handling);
Options {
simplified_ui,
@@ -729,6 +773,7 @@ impl Options {
mobile_layout,
mobile_threshold_cols,
mobile_threshold_rows,
+ nested_session_handling,
}
}
diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs
index d5edbbb4..2e36cc96 100644
--- a/zellij-utils/src/ipc.rs
+++ b/zellij-utils/src/ipc.rs
@@ -176,6 +176,9 @@ pub enum ClientToServerMsg {
SoftKeyboardVisibilityChanged {
visible: bool,
},
+ NestedSessionFrameFromHost {
+ payload_bytes: Vec<u8>,
+ },
}
// Types of messages sent from the server to the client
@@ -227,6 +230,9 @@ pub enum ServerToClientMsg {
token: u32,
query_bytes: Vec<u8>,
},
+ EmitNestedSessionFrame {
+ payload_bytes: Vec<u8>,
+ },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs
index c129b6f7..6c0adc2b 100644
--- a/zellij-utils/src/ipc/protobuf_conversion.rs
+++ b/zellij-utils/src/ipc/protobuf_conversion.rs
@@ -5,19 +5,19 @@ use crate::{
client_to_server_msg, server_to_client_msg, ActionMsg, AttachClientMsg,
AttachWatcherClientMsg, BackgroundColorMsg, CliPipeOutputMsg, ClientExitedMsg,
ClientToServerMsg as ProtoClientToServerMsg, ColorRegistersMsg, ConfigFileUpdatedMsg,
- ConnStatusMsg, ConnectedMsg, DesktopNotificationResponseMsg, DetachSessionMsg, ExitMsg,
- ExitReason as ProtoExitReason, FailedToStartWebServerMsg, FirstClientConnectedMsg,
- ForegroundColorMsg, ForwardQueryToHostMsg, ForwardedReplyFromHostMsg,
- HostTerminalThemeChangedMsg,
+ ConnStatusMsg, ConnectedMsg, DesktopNotificationResponseMsg, DetachSessionMsg,
+ EmitNestedSessionFrameMsg, ExitMsg, ExitReason as ProtoExitReason,
+ FailedToStartWebServerMsg, FirstClientConnectedMsg, ForegroundColorMsg,
+ ForwardQueryToHostMsg, ForwardedReplyFromHostMsg, HostTerminalThemeChangedMsg,
HostTerminalThemeIndication as ProtoHostTerminalThemeIndication,
InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata,
- LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg,
- QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, ResizeCause as ProtoResizeCause,
- ServerToClientMsg as ProtoServerToClientMsg, SetSoftKeyboardMsg,
- SoftKeyboardVisibilityChangedMsg, StartWebServerMsg, SubscribeToPaneRendersMsg,
- SubscribedPaneClosedMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata,
- TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg,
- UnblockInputThreadMsg, WebServerStartedMsg,
+ LogErrorMsg, LogMsg, NestedSessionFrameFromHostMsg, PaneMetadata as ProtoPaneMetadata,
+ PaneRenderUpdateMsg, QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg,
+ ResizeCause as ProtoResizeCause, ServerToClientMsg as ProtoServerToClientMsg,
+ SetSoftKeyboardMsg, SoftKeyboardVisibilityChangedMsg, StartWebServerMsg,
+ SubscribeToPaneRendersMsg, SubscribedPaneClosedMsg, SwitchSessionMsg,
+ TabMetadata as ProtoTabMetadata, TerminalPixelDimensionsMsg, TerminalResizeMsg,
+ UnblockCliPipeInputMsg, UnblockInputThreadMsg, WebServerStartedMsg,
},
data::{HostTerminalThemeMode, InputMode, PaneId},
errors::prelude::*,
@@ -156,6 +156,11 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg {
SoftKeyboardVisibilityChangedMsg { visible },
)
},
+ ClientToServerMsg::NestedSessionFrameFromHost { payload_bytes } => {
+ client_to_server_msg::Message::NestedSessionFrameFromHost(
+ NestedSessionFrameFromHostMsg { payload_bytes },
+ )
+ },
};
ProtoClientToServerMsg {
@@ -306,6 +311,11 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg {
visible: msg.visible,
})
},
+ Some(client_to_server_msg::Message::NestedSessionFrameFromHost(msg)) => {
+ Ok(ClientToServerMsg::NestedSessionFrameFromHost {
+ payload_bytes: msg.payload_bytes,
+ })
+ },
None => Err(anyhow!("Empty ClientToServerMsg message")),
}
}
@@ -394,6 +404,11 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg {
ServerToClientMsg::SetSoftKeyboard { on } => {
server_to_client_msg::Message::SetSoftKeyboard(SetSoftKeyboardMsg { on })
},
+ ServerToClientMsg::EmitNestedSessionFrame { payload_bytes } => {
+ server_to_client_msg::Message::EmitNestedSessionFrame(EmitNestedSessionFrameMsg {
+ payload_bytes,
+ })
+ },
};
ProtoServerToClientMsg {
@@ -509,6 +524,11 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg {
Some(server_to_client_msg::Message::SetSoftKeyboard(msg)) => {
Ok(ServerToClientMsg::SetSoftKeyboard { on: msg.on })
},
+ Some(server_to_client_msg::Message::EmitNestedSessionFrame(msg)) => {
+ Ok(ServerToClientMsg::EmitNestedSessionFrame {
+ payload_bytes: msg.payload_bytes,
+ })
+ },
None => Err(anyhow!("Empty ServerToClientMsg message")),
}
}
@@ -775,6 +795,18 @@ impl From<crate::input::options::Options>
crate::input::options::PaneFrameStyle::Titles => "titles".to_owned(),
crate::input::options::PaneFrameStyle::None => "none".to_owned(),
}),
+ nested_session_handling: options.nested_session_handling.map(|n| {
+ use crate::client_server_contract::client_server_contract::NestedSessionHandling as ProtoNestedSessionHandling;
+ use crate::input::options::NestedSessionHandling;
+ match n {
+ NestedSessionHandling::Ask => ProtoNestedSessionHandling::Ask as i32,
+ NestedSessionHandling::Fullscreen => {
+ ProtoNestedSessionHandling::Fullscreen as i32
+ },
+ NestedSessionHandling::Descend => ProtoNestedSessionHandling::Descend as i32,
+ NestedSessionHandling::Never => ProtoNestedSessionHandling::Never as i32,
+ }
+ }),
}
}
}
@@ -788,7 +820,8 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
) -> Result<Self> {
use crate::client_server_contract::client_server_contract::{
Clipboard as ProtoClipboard, MobileLayout as ProtoMobileLayout,
- OnForceClose as ProtoOnForceClose, WebSharing as ProtoWebSharing,
+ NestedSessionHandling as ProtoNestedSessionHandling, OnForceClose as ProtoOnForceClose,
+ WebSharing as ProtoWebSharing,
};
Ok(Self {
@@ -897,6 +930,24 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
.transpose()?,
mobile_threshold_cols: options.mobile_threshold_cols.map(|v| v as u16),
mobile_threshold_rows: options.mobile_threshold_rows.map(|v| v as u16),
+ nested_session_handling: options
+ .nested_session_handling
+ .map(|n| match ProtoNestedSessionHandling::from_i32(n) {
+ Some(ProtoNestedSessionHandling::Ask) => {
+ Ok(crate::input::options::NestedSessionHandling::Ask)
+ },
+ Some(ProtoNestedSessionHandling::Fullscreen) => {
+ Ok(crate::input::options::NestedSessionHandling::Fullscreen)
+ },
+ Some(ProtoNestedSessionHandling::Descend) => {
+ Ok(crate::input::options::NestedSessionHandling::Descend)
+ },
+ Some(ProtoNestedSessionHandling::Never) => {
+ Ok(crate::input::options::NestedSessionHandling::Never)
+ },
+ _ => Err(anyhow!("Invalid NestedSessionHandling value: {}", n)),
+ })
+ .transpose()?,
})
}
}
@@ -932,6 +983,8 @@ impl From<crate::input::actions::Action>
EditFileAction,
EditScrollbackAction,
EditScrollbackByPaneIdAction,
+ FocusGuestSessionAction,
+ FocusHostSessionAction,
FocusLastPaneAction,
FocusNextPaneAction,
FocusPaneByPaneIdAction,
@@ -1031,9 +1084,12 @@ impl From<crate::input::actions::Action>
ToggleFloatingPanesAction,
ToggleFloatingPanesByTabIdAction,
ToggleFocusFullscreenAction,
+ ToggleFocusNoUiFullscreenAction,
ToggleFullscreenByPaneIdAction,
ToggleGroupMarkingAction,
+ ToggleHostFullscreenAction,
ToggleMouseModeAction,
+ ToggleNoUiFullscreenByPaneIdAction,
TogglePaneBorderlessAction,
TogglePaneEmbedOrFloatingAction,
TogglePaneEmbedOrFloatingByPaneIdAction,
@@ -1112,6 +1168,15 @@ impl From<crate::input::actions::Action>
crate::input::actions::Action::FocusLastPane => {
ActionType::FocusLastPane(FocusLastPaneAction {})
},
+ crate::input::actions::Action::FocusHostSession => {
+ ActionType::FocusHostSession(FocusHostSessionAction {})
+ },
+ crate::input::actions::Action::FocusGuestSession => {
+ ActionType::FocusGuestSession(FocusGuestSessionAction {})
+ },
+ crate::input::actions::Action::ToggleHostFullscreen => {
+ ActionType::ToggleHostFullscreen(ToggleHostFullscreenAction {})
+ },
crate::input::actions::Action::SwitchFocus => {
ActionType::SwitchFocus(SwitchFocusAction {})
},
@@ -1192,6 +1257,9 @@ impl From<crate::input::actions::Action>
crate::input::actions::Action::ToggleFocusFullscreen => {
ActionType::ToggleFocusFullscreen(ToggleFocusFullscreenAction {})
},
+ crate::input::actions::Action::ToggleFocusNoUiFullscreen => {
+ ActionType::ToggleFocusNoUiFullscreen(ToggleFocusNoUiFullscreenAction {})
+ },
crate::input::actions::Action::TogglePaneFrames => {
ActionType::TogglePaneFrames(TogglePaneFramesAction {})
},
@@ -1877,6 +1945,11 @@ impl From<crate::input::actions::Action>
pane_id: Some(pane_id.into()),
})
},
+ crate::input::actions::Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => {
+ ActionType::ToggleNoUiFullscreenByPaneId(ToggleNoUiFullscreenByPaneIdAction {
+ pane_id: Some(pane_id.into()),
+ })
+ },
crate::input::actions::Action::TogglePaneEmbedOrFloatingByPaneId { pane_id } => {
ActionType::TogglePaneEmbedOrFloatingByPaneId(
TogglePaneEmbedOrFloatingByPaneIdAction {
@@ -2086,6 +2159,9 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action>
ActionType::ToggleFocusFullscreen(_) => {
Ok(crate::input::actions::Action::ToggleFocusFullscreen)
},
+ ActionType::ToggleFocusNoUiFullscreen(_) => {
+ Ok(crate::input::actions::Action::ToggleFocusNoUiFullscreen)
+ },
ActionType::TogglePaneFrames(_) => Ok(crate::input::actions::Action::TogglePaneFrames),
ActionType::SetPaneFrameStyle(set_pane_frame_style_action) => {
let style = crate::input::options::PaneFrameStyle::from_str(
@@ -2552,6 +2628,13 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action>
ActionType::BreakPane(_) => Ok(crate::input::actions::Action::BreakPane),
ActionType::BreakPaneRight(_) => Ok(crate::input::actions::Action::BreakPaneRight),
ActionType::BreakPaneLeft(_) => Ok(crate::input::actions::Action::BreakPaneLeft),
+ ActionType::FocusHostSession(_) => Ok(crate::input::actions::Action::FocusHostSession),
+ ActionType::FocusGuestSession(_) => {
+ Ok(crate::input::actions::Action::FocusGuestSession)
+ },
+ ActionType::ToggleHostFullscreen(_) => {
+ Ok(crate::input::actions::Action::ToggleHostFullscreen)
+ },
ActionType::RenameSession(rename_session_action) => {
Ok(crate::input::actions::Action::RenameSession {
name: rename_session_action.name,
@@ -2810,6 +2893,14 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action>
.try_into()?,
},
),
+ ActionType::ToggleNoUiFullscreenByPaneId(a) => Ok(
+ crate::input::actions::Action::ToggleFocusNoUiFullscreenByPaneId {
+ pane_id: a
+ .pane_id
+ .ok_or_else(|| anyhow!("ToggleNoUiFullscreenByPaneId missing pane_id"))?
+ .try_into()?,
+ },
+ ),
ActionType::TogglePaneEmbedOrFloatingByPaneId(a) => Ok(
crate::input::actions::Action::TogglePaneEmbedOrFloatingByPaneId {
pane_id: a
diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
index 37214782..f8e6dc91 100644
--- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs
+++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
@@ -14,7 +14,8 @@ use crate::input::layout::{
};
use crate::input::mouse::{MouseEvent, MouseEventType};
use crate::input::options::{
- Clipboard, MobileLayoutConfiguration, OnForceClose, Options, PaneFrameStyle,
+ Clipboard, MobileLayoutConfiguration, NestedSessionHandling, OnForceClose, Options,
+ PaneFrameStyle,
};
use crate::ipc::{
ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, ResizeCause,
@@ -487,6 +488,7 @@ fn test_client_messages() {
mobile_layout: Some(MobileLayoutConfiguration::Always),
mobile_threshold_cols: Some(72),
mobile_threshold_rows: Some(40),
+ nested_session_handling: Some(NestedSessionHandling::Fullscreen),
}),
layout: None,
terminal_window_size: Size { rows: 80, cols: 42 },
@@ -678,6 +680,46 @@ fn test_client_messages() {
},
is_web_client: true,
});
+ test_client_roundtrip!(ClientToServerMsg::FirstClientConnected {
+ cli_assets: CliAssets {
+ configuration_options: Some(Options {
+ nested_session_handling: Some(NestedSessionHandling::Ask),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ is_web_client: true,
+ });
+ test_client_roundtrip!(ClientToServerMsg::FirstClientConnected {
+ cli_assets: CliAssets {
+ configuration_options: Some(Options {
+ nested_session_handling: Some(NestedSessionHandling::Fullscreen),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ is_web_client: true,
+ });
+ test_client_roundtrip!(ClientToServerMsg::FirstClientConnected {
+ cli_assets: CliAssets {
+ configuration_options: Some(Options {
+ nested_session_handling: Some(NestedSessionHandling::Descend),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ is_web_client: true,
+ });
+ test_client_roundtrip!(ClientToServerMsg::FirstClientConnected {
+ cli_assets: CliAssets {
+ configuration_options: Some(Options {
+ nested_session_handling: Some(NestedSessionHandling::Never),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ is_web_client: true,
+ });
test_client_roundtrip!(ClientToServerMsg::AttachClient {
cli_assets: CliAssets::default(),
tab_position_to_focus: None,
@@ -708,6 +750,18 @@ fn test_client_messages() {
}),
is_web_client: true,
});
+ test_client_roundtrip!(ClientToServerMsg::AttachClient {
+ cli_assets: CliAssets {
+ configuration_options: Some(Options {
+ nested_session_handling: Some(NestedSessionHandling::Descend),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ tab_position_to_focus: None,
+ pane_to_focus: None,
+ is_web_client: false,
+ });
// TODO: Action
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::Quit,
@@ -1014,6 +1068,12 @@ fn test_client_messages() {
is_cli_client: true,
});
test_client_roundtrip!(ClientToServerMsg::Action {
+ action: Action::ToggleFocusNoUiFullscreen,
+ terminal_id: Some(1),
+ client_id: Some(100),
+ is_cli_client: true,
+ });
+ test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::TogglePaneFrames,
terminal_id: Some(1),
client_id: Some(100),
@@ -3506,6 +3566,14 @@ fn test_client_messages() {
is_cli_client: true,
});
test_client_roundtrip!(ClientToServerMsg::Action {
+ action: Action::ToggleFocusNoUiFullscreenByPaneId {
+ pane_id: PaneId::Terminal(1),
+ },
+ terminal_id: Some(1),
+ client_id: Some(100),
+ is_cli_client: true,
+ });
+ test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::TogglePaneEmbedOrFloatingByPaneId {
pane_id: PaneId::Terminal(1),
},
@@ -3640,6 +3708,12 @@ fn test_client_messages() {
test_client_roundtrip!(ClientToServerMsg::HostTerminalThemeChanged {
mode: HostTerminalThemeMode::Light,
});
+ test_client_roundtrip!(ClientToServerMsg::NestedSessionFrameFromHost {
+ payload_bytes: vec![],
+ });
+ test_client_roundtrip!(ClientToServerMsg::NestedSessionFrameFromHost {
+ payload_bytes: (0u8..=255u8).collect(),
+ });
}
fn test_server_messages() {
@@ -3787,6 +3861,12 @@ fn test_server_messages() {
token: u32::MAX,
query_bytes: (0u8..=255u8).collect(),
});
+ test_server_roundtrip!(ServerToClientMsg::EmitNestedSessionFrame {
+ payload_bytes: vec![],
+ });
+ test_server_roundtrip!(ServerToClientMsg::EmitNestedSessionFrame {
+ payload_bytes: (0u8..=255u8).collect(),
+ });
}
#[test]
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index b16e7f12..4e7b3735 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -49,6 +49,9 @@ macro_rules! parse_kdl_action_arguments {
"FocusNextPane" => Ok(Action::FocusNextPane),
"FocusPreviousPane" => Ok(Action::FocusPreviousPane),
"FocusLastPane" => Ok(Action::FocusLastPane),
+ "FocusHostSession" => Ok(Action::FocusHostSession),
+ "FocusGuestSession" => Ok(Action::FocusGuestSession),
+ "ToggleHostFullscreen" => Ok(Action::ToggleHostFullscreen),
"SwitchFocus" => Ok(Action::SwitchFocus),
"EditScrollback" => Ok(Action::EditScrollback { ansi: false }),
"ScrollUp" => Ok(Action::ScrollUp),
@@ -60,6 +63,7 @@ macro_rules! parse_kdl_action_arguments {
"HalfPageScrollUp" => Ok(Action::HalfPageScrollUp),
"HalfPageScrollDown" => Ok(Action::HalfPageScrollDown),
"ToggleFocusFullscreen" => Ok(Action::ToggleFocusFullscreen),
+ "ToggleFocusNoUiFullscreen" => Ok(Action::ToggleFocusNoUiFullscreen),
"TogglePaneFrames" => Ok(Action::TogglePaneFrames),
"ToggleActiveSyncTab" => Ok(Action::ToggleActiveSyncTab),
"TogglePaneEmbedOrFloating" => Ok(Action::TogglePaneEmbedOrFloating),
@@ -744,6 +748,7 @@ impl Action {
Action::HalfPageScrollUp => Some(KdlNode::new("HalfPageScrollUp")),
Action::HalfPageScrollDown => Some(KdlNode::new("HalfPageScrollDown")),
Action::ToggleFocusFullscreen => Some(KdlNode::new("ToggleFocusFullscreen")),
+ Action::ToggleFocusNoUiFullscreen => Some(KdlNode::new("ToggleFocusNoUiFullscreen")),
Action::TogglePaneFrames => Some(KdlNode::new("TogglePaneFrames")),
Action::SetPaneFrameStyle(style) => {
let mut node = KdlNode::new("SetPaneFrameStyle");
@@ -1350,6 +1355,9 @@ impl Action {
Action::SetDarkTheme => Some(KdlNode::new("SetDarkTheme")),
Action::SetLightTheme => Some(KdlNode::new("SetLightTheme")),
Action::ToggleTheme => Some(KdlNode::new("ToggleTheme")),
+ Action::FocusHostSession => Some(KdlNode::new("FocusHostSession")),
+ Action::FocusGuestSession => Some(KdlNode::new("FocusGuestSession")),
+ Action::ToggleHostFullscreen => Some(KdlNode::new("ToggleHostFullscreen")),
_ => None,
}
}
@@ -1541,6 +1549,15 @@ impl TryFrom<(&KdlNode, &Options)> for Action {
"FocusLastPane" => {
parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
},
+ "FocusHostSession" => {
+ parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
+ },
+ "FocusGuestSession" => {
+ parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
+ },
+ "ToggleHostFullscreen" => {
+ parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
+ },
"SwitchFocus" => parse_kdl_action_arguments!(action_name, action_arguments, kdl_action),
"EditScrollback" => {
let ansi = crate::kdl_get_bool_property_or_child_value!(kdl_action, "ansi")
@@ -1570,6 +1587,9 @@ impl TryFrom<(&KdlNode, &Options)> for Action {
"ToggleFocusFullscreen" => {
parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
},
+ "ToggleFocusNoUiFullscreen" => {
+ parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
+ },
"TogglePaneFrames" => {
parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
},
@@ -2926,6 +2946,19 @@ impl Options {
},
None => None,
};
+ let nested_session_handling = match kdl_property_first_arg_as_string_or_error!(
+ kdl_options,
+ "nested_session_handling"
+ ) {
+ Some((value, entry)) => {
+ use crate::input::options::NestedSessionHandling;
+ match value.parse::<NestedSessionHandling>() {
+ Ok(v) => Some(v),
+ Err(e) => return Err(kdl_parsing_error!(e, entry)),
+ }
+ },
+ None => None,
+ };
Ok(Options {
simplified_ui,
@@ -2981,6 +3014,7 @@ impl Options {
mobile_layout,
mobile_threshold_cols,
mobile_threshold_rows,
+ nested_session_handling,
})
}
pub fn from_string(stringified_keybindings: &String) -> Result<Self, ConfigError> {
@@ -4505,6 +4539,44 @@ impl Options {
None
}
}
+ fn nested_session_handling_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ use crate::input::options::NestedSessionHandling;
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
+ " ",
+ "// How to handle a nested Zellij session detected inside a pane.",
+ "// Options:",
+ "// - \"ask\" (Default — prompt with a modal)",
+ "// - \"fullscreen\" (always zoom into the nested session)",
+ "// - \"descend\" (always control the nested session on focus)",
+ "// - \"never\" (never prompt or descend; do it manually)",
+ "// ",
+ );
+ let create_node = |value: NestedSessionHandling| -> KdlNode {
+ let mut node = KdlNode::new("nested_session_handling");
+ let s = match value {
+ NestedSessionHandling::Ask => "ask",
+ NestedSessionHandling::Fullscreen => "fullscreen",
+ NestedSessionHandling::Descend => "descend",
+ NestedSessionHandling::Never => "never",
+ };
+ node.push(KdlValue::String(s.to_string()));
+ node
+ };
+ if let Some(value) = self.nested_session_handling {
+ let mut node = create_node(value);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(NestedSessionHandling::Ask);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
fn client_async_worker_tasks_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = r#"
// Number of async worker tasks to spawn per active client.
@@ -4701,6 +4773,9 @@ impl Options {
if let Some(mobile_threshold_rows) = self.mobile_threshold_rows_to_kdl(add_comments) {
nodes.push(mobile_threshold_rows);
}
+ if let Some(nested_session_handling) = self.nested_session_handling_to_kdl(add_comments) {
+ nodes.push(nested_session_handling);
+ }
nodes
}
}
@@ -7586,6 +7661,38 @@ fn mobile_layout_kdl_round_trip_for_every_variant() {
}
#[test]
+fn nested_session_handling_kdl_round_trip_for_every_variant() {
+ use crate::input::options::NestedSessionHandling;
+ let cases = [
+ ("ask", NestedSessionHandling::Ask),
+ ("fullscreen", NestedSessionHandling::Fullscreen),
+ ("descend", NestedSessionHandling::Descend),
+ ("never", NestedSessionHandling::Never),
+ ];
+ for (value, expected) in cases {
+ let fake_config = format!(
+ r##"
+ nested_session_handling "{value}"
+ "##
+ );
+ let document: KdlDocument = fake_config.parse().unwrap();
+ let parsed = Options::from_kdl(&document).unwrap();
+ assert_eq!(
+ parsed.nested_session_handling,
+ Some(expected),
+ "case: {value}"
+ );
+
+ let mut serialized = Options::to_kdl(&parsed, false);
+ let mut fake_document = KdlDocument::new();
+ fake_document.nodes_mut().append(&mut serialized);
+ let reparsed =
+ Options::from_kdl(&fake_document.to_string().parse::<KdlDocument>().unwrap()).unwrap();
+ assert_eq!(parsed, reparsed, "round-trip mismatch for {value}");
+ }
+}
+
+#[test]
fn config_options_to_string_with_some_options() {
let fake_config = r##"
default_layout "compact"
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
index 1ab39935..2d7dec36 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
@@ -15,6 +15,7 @@ keybinds clear-defaults=true {
bind "d" { NewPane "down"; SwitchToMode "normal"; }
bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "normal"; }
bind "f" { ToggleFocusFullscreen; SwitchToMode "normal"; }
+ bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "normal"; }
bind "h" { MoveFocus "left"; }
bind "i" { TogglePanePinned; SwitchToMode "normal"; }
bind "j" { MoveFocus "down"; }
@@ -100,6 +101,8 @@ keybinds clear-defaults=true {
bind "w" { SearchToggleOption "Wrap"; }
}
session {
+ bind "[" { FocusGuestSession; SwitchToMode "normal"; }
+ bind "]" { FocusHostSession; SwitchToMode "normal"; }
bind "a" {
LaunchOrFocusPlugin "zellij:about" {
floating true
@@ -114,6 +117,7 @@ keybinds clear-defaults=true {
}
SwitchToMode "normal"
}
+ bind "f" { ToggleHostFullscreen; SwitchToMode "normal"; }
bind "l" {
LaunchOrFocusPlugin "zellij:layout-manager" {
floating true
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
index bf816932..bbbb1248 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
@@ -15,6 +15,7 @@ keybinds clear-defaults=true {
bind "d" { NewPane "down"; SwitchToMode "normal"; }
bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "normal"; }
bind "f" { ToggleFocusFullscreen; SwitchToMode "normal"; }
+ bind "Shift f" { ToggleFocusNoUiFullscreen; SwitchToMode "normal"; }
bind "h" { MoveFocus "left"; }
bind "i" { TogglePanePinned; SwitchToMode "normal"; }
bind "j" { MoveFocus "down"; }
@@ -100,6 +101,8 @@ keybinds clear-defaults=true {
bind "w" { SearchToggleOption "Wrap"; }
}
session {
+ bind "[" { FocusGuestSession; SwitchToMode "normal"; }
+ bind "]" { FocusHostSession; SwitchToMode "normal"; }
bind "a" {
LaunchOrFocusPlugin "zellij:about" {
floating true
@@ -114,6 +117,7 @@ keybinds clear-defaults=true {
}
SwitchToMode "normal"
}
+ bind "f" { ToggleHostFullscreen; SwitchToMode "normal"; }
bind "l" {
LaunchOrFocusPlugin "zellij:layout-manager" {
floating true
@@ -615,3 +619,12 @@ web_client {
// Row breakpoint for mobile_layout (web/always). 0 = always match.
// Default: 30
// mobile_threshold_rows 30
+
+// How to handle a nested Zellij session detected inside a pane.
+// Options:
+// - "ask" (Default — prompt with a modal)
+// - "fullscreen" (always zoom into the nested session)
+// - "descend" (always control the nested session on focus)
+// - "never" (never prompt or descend; do it manually)
+//
+// nested_session_handling "ask"
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
index 8dcbaadd..fe42491f 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
@@ -333,3 +333,12 @@ mobile_threshold_cols 72
// Row breakpoint for mobile_layout (web/always). 0 = always match.
// Default: 30
mobile_threshold_rows 0
+
+// How to handle a nested Zellij session detected inside a pane.
+// Options:
+// - "ask" (Default — prompt with a modal)
+// - "fullscreen" (always zoom into the nested session)
+// - "descend" (always control the nested session on focus)
+// - "never" (never prompt or descend; do it manually)
+//
+// nested_session_handling "ask"
diff --git a/zellij-utils/src/lib.rs b/zellij-utils/src/lib.rs
index ba02ce96..e7a2d797 100644
--- a/zellij-utils/src/lib.rs
+++ b/zellij-utils/src/lib.rs
@@ -11,6 +11,7 @@ mod home_unix;
mod home_windows;
pub mod input;
pub mod kdl;
+pub mod nested_session_contract;
pub mod pane_size;
pub mod plugin_api;
pub mod position;
@@ -29,6 +30,8 @@ pub mod downloader; // Requires tokio
pub mod ipc; // Requires interprocess
#[cfg(not(target_family = "wasm"))]
pub mod logging; // Requires log4rs
+#[cfg(not(target_family = "wasm"))]
+pub mod nested_session;
#[cfg(all(not(target_family = "wasm"), feature = "web_server_capability"))]
pub mod remote_session_tokens;
#[cfg(not(target_family = "wasm"))]
diff --git a/zellij-utils/src/nested_session.rs b/zellij-utils/src/nested_session.rs
new file mode 100644
index 00000000..4144defc
--- /dev/null
+++ b/zellij-utils/src/nested_session.rs
@@ -0,0 +1,470 @@
+use crate::data::{Direction, KeyWithModifier};
+use crate::nested_session_contract::nested_session_contract as proto;
+use prost::Message;
+use std::str::FromStr;
+
+pub const NESTED_DCS_PARAM: u16 = 26661;
+pub const NESTED_FRAME_HEADER: &[u8] = b"\x1bP26661n";
+pub const NESTED_FRAME_TERMINATOR: &[u8] = b"\x1b\\";
+
+pub const REANNOUNCE_SILENCE_MS: u64 = 3000;
+pub const REANNOUNCE_CHECK_INTERVAL_MS: u64 = 1000;
+
+pub fn reannounce_silence_ms() -> u64 {
+ std::env::var("ZELLIJ_NESTED_REANNOUNCE_SILENCE_MS")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(REANNOUNCE_SILENCE_MS)
+}
+
+pub fn reannounce_check_interval_ms() -> u64 {
+ std::env::var("ZELLIJ_NESTED_REANNOUNCE_CHECK_INTERVAL_MS")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(REANNOUNCE_CHECK_INTERVAL_MS)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NestedSessionCapability {
+ NestedControl,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum NestedSessionMessage {
+ Announce {
+ session_name: String,
+ capabilities: Vec<NestedSessionCapability>,
+ },
+ FocusHost {
+ direction: Option<Direction>,
+ },
+ ToggleHostFullscreen {
+ fullscreen: bool,
+ },
+ Pong,
+ Bye,
+ AnnounceAck {
+ ancestry: Vec<String>,
+ capabilities: Vec<NestedSessionCapability>,
+ descend_keys: Vec<KeyWithModifier>,
+ },
+ FocusGained {
+ from_direction: Option<Direction>,
+ },
+ FocusLost,
+ FullscreenState {
+ fullscreen: bool,
+ },
+ AncestryUpdate {
+ ancestry: Vec<String>,
+ },
+ Ping,
+ ShortcutUpdate {
+ ascend_keys: Vec<KeyWithModifier>,
+ descend_keys: Vec<KeyWithModifier>,
+ },
+}
+
+fn keys_to_proto(keys: &[KeyWithModifier]) -> Vec<String> {
+ keys.iter().map(|key| key.to_kdl()).collect()
+}
+
+fn keys_from_proto(keys: &[String]) -> Vec<KeyWithModifier> {
+ let parsed: Vec<KeyWithModifier> = keys
+ .iter()
+ .filter_map(|key| KeyWithModifier::from_str(key).ok())
+ .collect();
+ if parsed.len() == keys.len() {
+ parsed
+ } else {
+ vec![]
+ }
+}
+
+fn capabilities_to_proto(capabilities: &[NestedSessionCapability]) -> Vec<i32> {
+ capabilities
+ .iter()
+ .map(|capability| match capability {
+ NestedSessionCapability::NestedControl => proto::NestedCapability::NestedControl as i32,
+ })
+ .collect()
+}
+
+fn capabilities_from_proto(capabilities: &[i32]) -> Vec<NestedSessionCapability> {
+ capabilities
+ .iter()
+ .filter_map(
+ |capability| match proto::NestedCapability::from_i32(*capability) {
+ Some(proto::NestedCapability::NestedControl) => {
+ Some(NestedSessionCapability::NestedControl)
+ },
+ _ => None,
+ },
+ )
+ .collect()
+}
+
+fn direction_to_proto(direction: Option<Direction>) -> i32 {
+ match direction {
+ Some(Direction::Left) => proto::NestedDirection::Left as i32,
+ Some(Direction::Right) => proto::NestedDirection::Right as i32,
+ Some(Direction::Up) => proto::NestedDirection::Up as i32,
+ Some(Direction::Down) => proto::NestedDirection::Down as i32,
+ None => proto::NestedDirection::Unspecified as i32,
+ }
+}
+
+fn direction_from_proto(direction: i32) -> Option<Direction> {
+ match proto::NestedDirection::from_i32(direction) {
+ Some(proto::NestedDirection::Left) => Some(Direction::Left),
+ Some(proto::NestedDirection::Right) => Some(Direction::Right),
+ Some(proto::NestedDirection::Up) => Some(Direction::Up),
+ Some(proto::NestedDirection::Down) => Some(Direction::Down),
+ _ => None,
+ }
+}
+
+impl From<NestedSessionMessage> for proto::NestedSessionMessage {
+ fn from(message: NestedSessionMessage) -> Self {
+ use proto::nested_session_message::Payload;
+ let payload = match message {
+ NestedSessionMessage::Announce {
+ session_name,
+ capabilities,
+ } => Payload::Announce(proto::Announce {
+ session_name,
+ capabilities: capabilities_to_proto(&capabilities),
+ }),
+ NestedSessionMessage::FocusHost { direction } => Payload::FocusHost(proto::FocusHost {
+ direction: direction_to_proto(direction),
+ }),
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen } => {
+ Payload::HostFullscreen(proto::ToggleHostFullscreen { fullscreen })
+ },
+ NestedSessionMessage::Pong => Payload::Pong(proto::Pong {}),
+ NestedSessionMessage::Bye => Payload::Bye(proto::Bye {}),
+ NestedSessionMessage::AnnounceAck {
+ ancestry,
+ capabilities,
+ descend_keys,
+ } => Payload::AnnounceAck(proto::AnnounceAck {
+ ancestry,
+ capabilities: capabilities_to_proto(&capabilities),
+ descend_keys: keys_to_proto(&descend_keys),
+ }),
+ NestedSessionMessage::FocusGained { from_direction } => {
+ Payload::FocusGained(proto::FocusGained {
+ from_direction: direction_to_proto(from_direction),
+ })
+ },
+ NestedSessionMessage::FocusLost => Payload::FocusLost(proto::FocusLost {}),
+ NestedSessionMessage::FullscreenState { fullscreen } => {
+ Payload::FullscreenState(proto::FullscreenState { fullscreen })
+ },
+ NestedSessionMessage::AncestryUpdate { ancestry } => {
+ Payload::AncestryUpdate(proto::AncestryUpdate { ancestry })
+ },
+ NestedSessionMessage::Ping => Payload::Ping(proto::Ping {}),
+ NestedSessionMessage::ShortcutUpdate {
+ ascend_keys,
+ descend_keys,
+ } => Payload::ShortcutUpdate(proto::ShortcutUpdate {
+ ascend_keys: keys_to_proto(&ascend_keys),
+ descend_keys: keys_to_proto(&descend_keys),
+ }),
+ };
+ proto::NestedSessionMessage {
+ payload: Some(payload),
+ }
+ }
+}
+
+impl TryFrom<proto::NestedSessionMessage> for NestedSessionMessage {
+ type Error = ();
+ fn try_from(message: proto::NestedSessionMessage) -> Result<Self, Self::Error> {
+ use proto::nested_session_message::Payload;
+ match message.payload {
+ Some(Payload::Announce(announce)) => Ok(NestedSessionMessage::Announce {
+ session_name: announce.session_name,
+ capabilities: capabilities_from_proto(&announce.capabilities),
+ }),
+ Some(Payload::FocusHost(focus_host)) => Ok(NestedSessionMessage::FocusHost {
+ direction: direction_from_proto(focus_host.direction),
+ }),
+ Some(Payload::HostFullscreen(host_fullscreen)) => {
+ Ok(NestedSessionMessage::ToggleHostFullscreen {
+ fullscreen: host_fullscreen.fullscreen,
+ })
+ },
+ Some(Payload::Pong(_)) => Ok(NestedSessionMessage::Pong),
+ Some(Payload::Bye(_)) => Ok(NestedSessionMessage::Bye),
+ Some(Payload::AnnounceAck(announce_ack)) => Ok(NestedSessionMessage::AnnounceAck {
+ ancestry: announce_ack.ancestry,
+ capabilities: capabilities_from_proto(&announce_ack.capabilities),
+ descend_keys: keys_from_proto(&announce_ack.descend_keys),
+ }),
+ Some(Payload::FocusGained(focus_gained)) => Ok(NestedSessionMessage::FocusGained {
+ from_direction: direction_from_proto(focus_gained.from_direction),
+ }),
+ Some(Payload::FocusLost(_)) => Ok(NestedSessionMessage::FocusLost),
+ Some(Payload::FullscreenState(fullscreen_state)) => {
+ Ok(NestedSessionMessage::FullscreenState {
+ fullscreen: fullscreen_state.fullscreen,
+ })
+ },
+ Some(Payload::AncestryUpdate(ancestry_update)) => {
+ Ok(NestedSessionMessage::AncestryUpdate {
+ ancestry: ancestry_update.ancestry,
+ })
+ },
+ Some(Payload::Ping(_)) => Ok(NestedSessionMessage::Ping),
+ Some(Payload::ShortcutUpdate(shortcut_update)) => {
+ Ok(NestedSessionMessage::ShortcutUpdate {
+ ascend_keys: keys_from_proto(&shortcut_update.ascend_keys),
+ descend_keys: keys_from_proto(&shortcut_update.descend_keys),
+ })
+ },
+ None => Err(()),
+ }
+ }
+}
+
+pub fn encode_payload(message: &NestedSessionMessage) -> Vec<u8> {
+ let proto_message: proto::NestedSessionMessage = message.clone().into();
+ proto_message.encode_to_vec()
+}
+
+pub fn decode_payload(payload_bytes: &[u8]) -> Option<NestedSessionMessage> {
+ let proto_message = proto::NestedSessionMessage::decode(payload_bytes).ok()?;
+ NestedSessionMessage::try_from(proto_message).ok()
+}
+
+pub fn encode_frame(message: &NestedSessionMessage) -> Vec<u8> {
+ encode_frame_from_payload(&encode_payload(message))
+}
+
+pub fn encode_frame_from_payload(payload_bytes: &[u8]) -> Vec<u8> {
+ let encoded = base64::encode(payload_bytes);
+ let mut frame = Vec::with_capacity(
+ NESTED_FRAME_HEADER.len() + encoded.len() + NESTED_FRAME_TERMINATOR.len(),
+ );
+ frame.extend_from_slice(NESTED_FRAME_HEADER);
+ frame.extend_from_slice(encoded.as_bytes());
+ frame.extend_from_slice(NESTED_FRAME_TERMINATOR);
+ frame
+}
+
+pub fn decode_base64(encoded: &[u8]) -> Option<Vec<u8>> {
+ base64::decode(encoded).ok()
+}
+
+const MAX_PARTIAL_FRAME_BYTES: usize = 1024 * 1024;
+
+enum FrameScanStatus {
+ Complete(usize),
+ NeedMore,
+ Diverged,
+}
+
+fn frame_scan_status(buf: &[u8]) -> FrameScanStatus {
+ if buf.len() < NESTED_FRAME_HEADER.len() {
+ return if NESTED_FRAME_HEADER.starts_with(buf) {
+ FrameScanStatus::NeedMore
+ } else {
+ FrameScanStatus::Diverged
+ };
+ }
+ if &buf[..NESTED_FRAME_HEADER.len()] != NESTED_FRAME_HEADER {
+ return FrameScanStatus::Diverged;
+ }
+ let mut i = NESTED_FRAME_HEADER.len();
+ while i < buf.len() {
+ match buf[i] {
+ 0x1b => match buf.get(i + 1) {
+ Some(&b'\\') => return FrameScanStatus::Complete(i + 2),
+ Some(_) => return FrameScanStatus::Diverged,
+ None => return FrameScanStatus::NeedMore,
+ },
+ _ => i += 1,
+ }
+ }
+ FrameScanStatus::NeedMore
+}
+
+#[derive(Debug, Default)]
+pub struct NestedFrameExtractor {
+ partial_frame: Vec<u8>,
+}
+
+impl NestedFrameExtractor {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn extract(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<Vec<u8>>) {
+ let mut decoded_payloads = Vec::new();
+ if self.partial_frame.is_empty() && !bytes.contains(&0x1b) {
+ return (bytes.to_vec(), decoded_payloads);
+ }
+ let mut working = std::mem::take(&mut self.partial_frame);
+ working.extend_from_slice(bytes);
+ let mut cleaned = Vec::with_capacity(working.len());
+ let mut i = 0;
+ while i < working.len() {
+ let rest = &working[i..];
+ if rest[0] == 0x1b {
+ match frame_scan_status(rest) {
+ FrameScanStatus::Complete(len) => {
+ let encoded_payload =
+ &rest[NESTED_FRAME_HEADER.len()..len - NESTED_FRAME_TERMINATOR.len()];
+ if let Some(payload) = decode_base64(encoded_payload) {
+ decoded_payloads.push(payload);
+ }
+ i += len;
+ continue;
+ },
+ FrameScanStatus::NeedMore => {
+ let tail = rest.to_vec();
+ if tail.len() > MAX_PARTIAL_FRAME_BYTES {
+ cleaned.extend_from_slice(&tail);
+ } else {
+ self.partial_frame = tail;
+ }
+ return (cleaned, decoded_payloads);
+ },
+ FrameScanStatus::Diverged => {},
+ }
+ }
+ cleaned.push(working[i]);
+ i += 1;
+ }
+ (cleaned, decoded_payloads)
+ }
+
+ pub fn partial_bytes(&self) -> &[u8] {
+ &self.partial_frame
+ }
+
+ pub fn take_partial(&mut self) -> Vec<u8> {
+ std::mem::take(&mut self.partial_frame)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::data::BareKey;
+
+ fn all_message_arms() -> Vec<NestedSessionMessage> {
+ vec![
+ NestedSessionMessage::Announce {
+ session_name: "guest".to_owned(),
+ capabilities: vec![NestedSessionCapability::NestedControl],
+ },
+ NestedSessionMessage::FocusHost {
+ direction: Some(Direction::Left),
+ },
+ NestedSessionMessage::FocusHost { direction: None },
+ NestedSessionMessage::ToggleHostFullscreen { fullscreen: true },
+ NestedSessionMessage::Pong,
+ NestedSessionMessage::Bye,
+ NestedSessionMessage::AnnounceAck {
+ ancestry: vec!["outer".to_owned(), "middle".to_owned()],
+ capabilities: vec![NestedSessionCapability::NestedControl],
+ descend_keys: vec![
+ KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(),
+ KeyWithModifier::new(BareKey::Down),
+ ],
+ },
+ NestedSessionMessage::FocusGained {
+ from_direction: Some(Direction::Right),
+ },
+ NestedSessionMessage::FocusLost,
+ NestedSessionMessage::FullscreenState { fullscreen: true },
+ NestedSessionMessage::AncestryUpdate {
+ ancestry: vec!["outer".to_owned()],
+ },
+ NestedSessionMessage::Ping,
+ NestedSessionMessage::ShortcutUpdate {
+ ascend_keys: vec![
+ KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(),
+ KeyWithModifier::new(BareKey::Up),
+ ],
+ descend_keys: vec![],
+ },
+ ]
+ }
+
+ #[test]
+ fn payload_roundtrip_preserves_every_arm() {
+ for message in all_message_arms() {
+ let decoded = decode_payload(&encode_payload(&message));
+ assert_eq!(decoded, Some(message));
+ }
+ }
+
+ #[test]
+ fn focus_direction_roundtrip_preserves_every_direction() {
+ let directions = [
+ None,
+ Some(Direction::Left),
+ Some(Direction::Right),
+ Some(Direction::Up),
+ Some(Direction::Down),
+ ];
+ for direction in directions {
+ let focus_host = NestedSessionMessage::FocusHost { direction };
+ assert_eq!(
+ decode_payload(&encode_payload(&focus_host)),
+ Some(focus_host)
+ );
+ let focus_gained = NestedSessionMessage::FocusGained {
+ from_direction: direction,
+ };
+ assert_eq!(
+ decode_payload(&encode_payload(&focus_gained)),
+ Some(focus_gained)
+ );
+ }
+ }
+
+ #[test]
+ fn frame_roundtrip_preserves_message() {
+ let message = NestedSessionMessage::Announce {
+ session_name: "guest".to_owned(),
+ capabilities: vec![NestedSessionCapability::NestedControl],
+ };
+ let frame = encode_frame(&message);
+ assert!(frame.starts_with(NESTED_FRAME_HEADER));
+ assert!(frame.ends_with(NESTED_FRAME_TERMINATOR));
+ let encoded_payload =
+ &frame[NESTED_FRAME_HEADER.len()..frame.len() - NESTED_FRAME_TERMINATOR.len()];
+ let payload = decode_base64(encoded_payload).unwrap();
+ assert_eq!(decode_payload(&payload), Some(message));
+ }
+
+ #[test]
+ fn garbage_base64_is_rejected() {
+ assert_eq!(decode_base64(b"!!!not-base64!!!"), None);
+ }
+
+ #[test]
+ fn truncated_payload_is_rejected() {
+ let payload = encode_payload(&NestedSessionMessage::Announce {
+ session_name: "a-long-session-name-to-truncate".to_owned(),
+ capabilities: vec![],
+ });
+ assert_eq!(decode_payload(&payload[..payload.len() - 5]), None);
+ }
+
+ #[test]
+ fn unknown_oneof_arm_is_rejected() {
+ let unknown_field_bytes = [0xE2, 0x03, 0x00];
+ assert_eq!(decode_payload(&unknown_field_bytes), None);
+ }
+
+ #[test]
+ fn empty_payload_is_rejected() {
+ assert_eq!(decode_payload(&[]), None);
+ }
+}
diff --git a/zellij-utils/src/nested_session_contract/mod.rs b/zellij-utils/src/nested_session_contract/mod.rs
new file mode 100644
index 00000000..5ec943c2
--- /dev/null
+++ b/zellij-utils/src/nested_session_contract/mod.rs
@@ -0,0 +1,4 @@
+include!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/assets/prost_nested_session/generated_nested_session_api.rs"
+));
diff --git a/zellij-utils/src/nested_session_contract/nested_session_message.proto b/zellij-utils/src/nested_session_contract/nested_session_message.proto
new file mode 100644
index 00000000..dfe2f62a
--- /dev/null
+++ b/zellij-utils/src/nested_session_contract/nested_session_message.proto
@@ -0,0 +1,80 @@
+syntax = "proto3";
+package nested_session_contract;
+
+message NestedSessionMessage {
+ oneof payload {
+ Announce announce = 1;
+ FocusHost focus_host = 2;
+ ToggleHostFullscreen host_fullscreen = 3;
+ Pong pong = 4;
+ Bye bye = 5;
+ AnnounceAck announce_ack = 8;
+ FocusGained focus_gained = 9;
+ FocusLost focus_lost = 10;
+ FullscreenState fullscreen_state = 11;
+ AncestryUpdate ancestry_update = 12;
+ Ping ping = 13;
+ ShortcutUpdate shortcut_update = 14;
+ }
+}
+
+enum NestedCapability {
+ NESTED_CAPABILITY_UNSPECIFIED = 0;
+ NESTED_CAPABILITY_NESTED_CONTROL = 1;
+}
+
+enum NestedDirection {
+ NESTED_DIRECTION_UNSPECIFIED = 0;
+ NESTED_DIRECTION_LEFT = 1;
+ NESTED_DIRECTION_RIGHT = 2;
+ NESTED_DIRECTION_UP = 3;
+ NESTED_DIRECTION_DOWN = 4;
+}
+
+message Announce {
+ string session_name = 1;
+ repeated NestedCapability capabilities = 2;
+}
+
+message FocusHost {
+ NestedDirection direction = 1;
+}
+
+message ToggleHostFullscreen {
+ bool fullscreen = 1;
+}
+
+message Pong {
+}
+
+message Bye {
+}
+
+message AnnounceAck {
+ repeated string ancestry = 1;
+ repeated NestedCapability capabilities = 2;
+ repeated string descend_keys = 3;
+}
+
+message ShortcutUpdate {
+ repeated string ascend_keys = 1;
+ repeated string descend_keys = 2;
+}
+
+message FocusGained {
+ NestedDirection from_direction = 1;
+}
+
+message FocusLost {
+}
+
+message FullscreenState {
+ bool fullscreen = 1;
+}
+
+message AncestryUpdate {
+ repeated string ancestry = 1;
+}
+
+message Ping {
+}
diff --git a/zellij-utils/src/plugin_api/action.proto b/zellij-utils/src/plugin_api/action.proto
index f9370961..1d896cef 100644
--- a/zellij-utils/src/plugin_api/action.proto
+++ b/zellij-utils/src/plugin_api/action.proto
@@ -501,6 +501,10 @@ enum ActionName {
ToggleTheme = 103;
FocusLastPane = 104;
SetPaneFrameStyle = 105;
+ ToggleFocusNoUiFullscreen = 106;
+ FocusHostSession = 107;
+ ToggleHostFullscreen = 108;
+ FocusGuestSession = 109;
}
message Position {
diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs
index f1d71222..c11b56f1 100644
--- a/zellij-utils/src/plugin_api/action.rs
+++ b/zellij-utils/src/plugin_api/action.rs
@@ -298,6 +298,12 @@ impl TryFrom<ProtobufAction> for Action {
None => Ok(Action::ToggleFocusFullscreen),
}
},
+ Some(ProtobufActionName::ToggleFocusNoUiFullscreen) => {
+ match protobuf_action.optional_payload {
+ Some(_) => Err("ToggleFocusNoUiFullscreen should not have a payload"),
+ None => Ok(Action::ToggleFocusNoUiFullscreen),
+ }
+ },
Some(ProtobufActionName::TogglePaneFrames) => match protobuf_action.optional_payload {
Some(_) => Err("TogglePaneFrames should not have a payload"),
None => Ok(Action::TogglePaneFrames),
@@ -1092,6 +1098,20 @@ impl TryFrom<ProtobufAction> for Action {
},
_ => Err("Wrong payload for Action::NewInPlacePane"),
},
+ Some(ProtobufActionName::FocusHostSession) => match protobuf_action.optional_payload {
+ Some(_) => Err("FocusHostSession should not have a payload"),
+ None => Ok(Action::FocusHostSession),
+ },
+ Some(ProtobufActionName::FocusGuestSession) => match protobuf_action.optional_payload {
+ Some(_) => Err("FocusGuestSession should not have a payload"),
+ None => Ok(Action::FocusGuestSession),
+ },
+ Some(ProtobufActionName::ToggleHostFullscreen) => {
+ match protobuf_action.optional_payload {
+ Some(_) => Err("ToggleHostFullscreen should not have a payload"),
+ None => Ok(Action::ToggleHostFullscreen),
+ }
+ },
_ => Err("Unknown Action"),
}
}
@@ -1148,6 +1168,7 @@ impl TryFrom<Action> for ProtobufAction {
| Action::ClearScreenByPaneId { .. }
| Action::EditScrollbackByPaneId { .. }
| Action::ToggleFocusFullscreenByPaneId { .. }
+ | Action::ToggleFocusNoUiFullscreenByPaneId { .. }
| Action::TogglePaneEmbedOrFloatingByPaneId { .. }
| Action::CloseFocusByPaneId { .. }
| Action::RenamePaneByPaneId { .. }
@@ -1323,6 +1344,10 @@ impl TryFrom<Action> for ProtobufAction {
name: ProtobufActionName::ToggleFocusFullscreen as i32,
optional_payload: None,
}),
+ Action::ToggleFocusNoUiFullscreen => Ok(ProtobufAction {
+ name: ProtobufActionName::ToggleFocusNoUiFullscreen as i32,
+ optional_payload: None,
+ }),
Action::TogglePaneFrames => Ok(ProtobufAction {
name: ProtobufActionName::TogglePaneFrames as i32,
optional_payload: None,
@@ -1960,6 +1985,18 @@ impl TryFrom<Action> for ProtobufAction {
)),
})
},
+ Action::FocusHostSession => Ok(ProtobufAction {
+ name: ProtobufActionName::FocusHostSession as i32,
+ optional_payload: None,
+ }),
+ Action::FocusGuestSession => Ok(ProtobufAction {
+ name: ProtobufActionName::FocusGuestSession as i32,
+ optional_payload: None,
+ }),
+ Action::ToggleHostFullscreen => Ok(ProtobufAction {
+ name: ProtobufActionName::ToggleHostFullscreen as i32,
+ optional_payload: None,
+ }),
Action::NoOp
| Action::Confirm
| Action::NewInPlacePluginPane {
diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto
index d3148edf..6814e8d8 100644
--- a/zellij-utils/src/plugin_api/event.proto
+++ b/zellij-utils/src/plugin_api/event.proto
@@ -511,6 +511,12 @@ message ModeUpdatePayload {
optional uint32 web_server_port = 14;
optional bool web_server_capability = 15;
optional PaneFrameStyle pane_frame_style = 16;
+ optional bool session_dimmed = 17;
+ repeated string ancestry = 18;
+ optional bool host_fullscreen = 19;
+ repeated string nested_ascend_keys = 20;
+ optional bool session_ascended = 21;
+ repeated string nested_descend_keys = 22;
}
enum WebSharing {
diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index 190552d4..970f5e2c 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -2020,6 +2020,26 @@ impl TryFrom<ProtobufModeUpdatePayload> for ModeInfo {
.and_then(|p| ProtobufPaneFrameStyle::from_i32(p))
.map(|p| p.into());
+ let session_dimmed = protobuf_mode_update_payload.session_dimmed;
+
+ let session_ancestry = protobuf_mode_update_payload.ancestry;
+
+ let host_fullscreen = protobuf_mode_update_payload.host_fullscreen;
+
+ let nested_ascend_keys = protobuf_mode_update_payload
+ .nested_ascend_keys
+ .iter()
+ .filter_map(|key| KeyWithModifier::from_str(key).ok())
+ .collect();
+
+ let session_ascended = protobuf_mode_update_payload.session_ascended;
+
+ let nested_descend_keys = protobuf_mode_update_payload
+ .nested_descend_keys
+ .iter()
+ .filter_map(|key| KeyWithModifier::from_str(key).ok())
+ .collect();
+
let mode_info = ModeInfo {
mode: current_mode,
keybinds,
@@ -2037,6 +2057,12 @@ impl TryFrom<ProtobufModeUpdatePayload> for ModeInfo {
web_server_port,
web_server_capability,
pane_frame_style,
+ session_dimmed,
+ session_ancestry,
+ host_fullscreen,
+ nested_ascend_keys,
+ session_ascended,
+ nested_descend_keys,
};
Ok(mode_info)
}
@@ -2065,6 +2091,20 @@ impl TryFrom<ModeInfo> for ProtobufModeUpdatePayload {
let protobuf_pane_frame_style: ProtobufPaneFrameStyle = p.into();
protobuf_pane_frame_style as i32
});
+ let session_dimmed = mode_info.session_dimmed;
+ let session_ancestry = mode_info.session_ancestry;
+ let host_fullscreen = mode_info.host_fullscreen;
+ let nested_ascend_keys = mode_info
+ .nested_ascend_keys
+ .iter()
+ .map(|key| key.to_kdl())
+ .collect();
+ let session_ascended = mode_info.session_ascended;
+ let nested_descend_keys = mode_info
+ .nested_descend_keys
+ .iter()
+ .map(|key| key.to_kdl())
+ .collect();
let mut protobuf_input_mode_keybinds: Vec<ProtobufInputModeKeybinds> = vec![];
for (input_mode, input_mode_keybinds) in mode_info.keybinds {
let mode: ProtobufInputMode = input_mode.try_into()?;
@@ -2106,6 +2146,12 @@ impl TryFrom<ModeInfo> for ProtobufModeUpdatePayload {
web_server_port,
web_server_capability,
pane_frame_style,
+ session_dimmed,
+ ancestry: session_ancestry,
+ host_fullscreen,
+ nested_ascend_keys,
+ session_ascended,
+ nested_descend_keys,
})
}
}
@@ -2460,6 +2506,18 @@ fn serialize_mode_update_event_with_non_default_values() {
web_server_port: Some(8082),
web_server_capability: Some(true),
pane_frame_style: Some(crate::input::options::PaneFrameStyle::Titles),
+ session_dimmed: Some(true),
+ session_ancestry: vec!["work".to_owned(), "prod".to_owned()],
+ host_fullscreen: Some(true),
+ nested_ascend_keys: vec![
+ KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(),
+ KeyWithModifier::new(BareKey::Up),
+ ],
+ session_ascended: Some(true),
+ nested_descend_keys: vec![
+ KeyWithModifier::new(BareKey::Char('o')).with_ctrl_modifier(),
+ KeyWithModifier::new(BareKey::Down),
+ ],
});
let protobuf_event: ProtobufEvent = mode_update_event.clone().try_into().unwrap();
let serialized_protobuf_event = protobuf_event.encode_to_vec();
diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto
index 8d31727d..e9dff3c6 100644
--- a/zellij-utils/src/plugin_api/plugin_command.proto
+++ b/zellij-utils/src/plugin_api/plugin_command.proto
@@ -218,6 +218,8 @@ enum CommandName {
SetPaneFrameStyle = 224;
ToggleFloatingPanes = 225;
NewPane = 226;
+ ToggleFocusNoUiFullscreen = 227;
+ FocusHostSession = 228;
}
message PluginCommand {
diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs
index 38c42565..3d86bc20 100644
--- a/zellij-utils/src/plugin_api/plugin_command.rs
+++ b/zellij-utils/src/plugin_api/plugin_command.rs
@@ -1110,6 +1110,18 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
}
Ok(PluginCommand::ToggleFocusFullscreen)
},
+ Some(CommandName::ToggleFocusNoUiFullscreen) => {
+ if protobuf_plugin_command.payload.is_some() {
+ return Err("ToggleFocusNoUiFullscreen should not have a payload");
+ }
+ Ok(PluginCommand::ToggleFocusNoUiFullscreen)
+ },
+ Some(CommandName::FocusHostSession) => {
+ if protobuf_plugin_command.payload.is_some() {
+ return Err("FocusHostSession should not have a payload");
+ }
+ Ok(PluginCommand::FocusHostSession)
+ },
Some(CommandName::TogglePaneFrames) => {
if protobuf_plugin_command.payload.is_some() {
return Err("TogglePaneFrames should not have a payload");
@@ -3127,6 +3139,14 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
name: CommandName::ToggleFocusFullscreen as i32,
payload: None,
}),
+ PluginCommand::ToggleFocusNoUiFullscreen => Ok(ProtobufPluginCommand {
+ name: CommandName::ToggleFocusNoUiFullscreen as i32,
+ payload: None,
+ }),
+ PluginCommand::FocusHostSession => Ok(ProtobufPluginCommand {
+ name: CommandName::FocusHostSession as i32,
+ payload: None,
+ }),
PluginCommand::TogglePaneFrames => Ok(ProtobufPluginCommand {
name: CommandName::TogglePaneFrames as i32,
payload: None,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
index 1bef8e12..cd08644a 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
@@ -58,4 +58,5 @@ Options {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
index 71d15cbe..26de6b99 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
@@ -58,4 +58,5 @@ Options {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
index 85885291..6242e4d9 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
@@ -56,4 +56,5 @@ Options {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
index 54404196..4f2869c9 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
@@ -1073,6 +1073,19 @@ Config {
],
KeyWithModifier {
bare_key: Char(
+ 'f',
+ ),
+ key_modifiers: {
+ Shift,
+ },
+ }: [
+ ToggleFocusNoUiFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
'g',
),
key_modifiers: {
@@ -4150,6 +4163,17 @@ Config {
bare_key: Char(
'[',
),
+ key_modifiers: {},
+ }: [
+ FocusGuestSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ '[',
+ ),
key_modifiers: {
Alt,
},
@@ -4160,6 +4184,17 @@ Config {
bare_key: Char(
']',
),
+ key_modifiers: {},
+ }: [
+ FocusHostSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ ']',
+ ),
key_modifiers: {
Alt,
},
@@ -4252,6 +4287,17 @@ Config {
bare_key: Char(
'f',
),
+ key_modifiers: {},
+ }: [
+ ToggleHostFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 'f',
+ ),
key_modifiers: {
Alt,
},
@@ -6035,6 +6081,7 @@ Config {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
},
themes: {},
plugins: PluginAliases {
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
index 6758d313..5353631b 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
@@ -1073,6 +1073,19 @@ Config {
],
KeyWithModifier {
bare_key: Char(
+ 'f',
+ ),
+ key_modifiers: {
+ Shift,
+ },
+ }: [
+ ToggleFocusNoUiFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
'g',
),
key_modifiers: {
@@ -4150,6 +4163,17 @@ Config {
bare_key: Char(
'[',
),
+ key_modifiers: {},
+ }: [
+ FocusGuestSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ '[',
+ ),
key_modifiers: {
Alt,
},
@@ -4160,6 +4184,17 @@ Config {
bare_key: Char(
']',
),
+ key_modifiers: {},
+ }: [
+ FocusHostSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ ']',
+ ),
key_modifiers: {
Alt,
},
@@ -4252,6 +4287,17 @@ Config {
bare_key: Char(
'f',
),
+ key_modifiers: {},
+ }: [
+ ToggleHostFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 'f',
+ ),
key_modifiers: {
Alt,
},
@@ -6035,6 +6081,7 @@ Config {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
},
themes: {},
plugins: PluginAliases {
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
index e38dc3bc..d907d5b0 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
@@ -143,6 +143,7 @@ Config {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
},
themes: {},
plugins: PluginAliases {
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
index 58a3028b..ff54b105 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
@@ -58,4 +58,5 @@ Options {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
index c68392b2..624cf0b0 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
@@ -1073,6 +1073,19 @@ Config {
],
KeyWithModifier {
bare_key: Char(
+ 'f',
+ ),
+ key_modifiers: {
+ Shift,
+ },
+ }: [
+ ToggleFocusNoUiFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
'g',
),
key_modifiers: {
@@ -4150,6 +4163,17 @@ Config {
bare_key: Char(
'[',
),
+ key_modifiers: {},
+ }: [
+ FocusGuestSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ '[',
+ ),
key_modifiers: {
Alt,
},
@@ -4160,6 +4184,17 @@ Config {
bare_key: Char(
']',
),
+ key_modifiers: {},
+ }: [
+ FocusHostSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ ']',
+ ),
key_modifiers: {
Alt,
},
@@ -4252,6 +4287,17 @@ Config {
bare_key: Char(
'f',
),
+ key_modifiers: {},
+ }: [
+ ToggleHostFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 'f',
+ ),
key_modifiers: {
Alt,
},
@@ -6035,6 +6081,7 @@ Config {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
},
themes: {
"other-theme-from-config": Theme {
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
index 0609b74b..0cfece6e 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
@@ -1073,6 +1073,19 @@ Config {
],
KeyWithModifier {
bare_key: Char(
+ 'f',
+ ),
+ key_modifiers: {
+ Shift,
+ },
+ }: [
+ ToggleFocusNoUiFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
'g',
),
key_modifiers: {
@@ -4150,6 +4163,17 @@ Config {
bare_key: Char(
'[',
),
+ key_modifiers: {},
+ }: [
+ FocusGuestSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ '[',
+ ),
key_modifiers: {
Alt,
},
@@ -4160,6 +4184,17 @@ Config {
bare_key: Char(
']',
),
+ key_modifiers: {},
+ }: [
+ FocusHostSession,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ ']',
+ ),
key_modifiers: {
Alt,
},
@@ -4252,6 +4287,17 @@ Config {
bare_key: Char(
'f',
),
+ key_modifiers: {},
+ }: [
+ ToggleHostFullscreen,
+ SwitchToMode {
+ input_mode: Normal,
+ },
+ ],
+ KeyWithModifier {
+ bare_key: Char(
+ 'f',
+ ),
key_modifiers: {
Alt,
},
@@ -6035,6 +6081,7 @@ Config {
mobile_layout: None,
mobile_threshold_cols: None,
mobile_threshold_rows: None,
+ nested_session_handling: None,
},
themes: {},
plugins: PluginAliases {