From ecd05547d542198be39596364e4c87d243247f9a Mon Sep 17 00:00:00 2001
From: Wez Furlong <wez@wezfurlong.org>
Date: Sat, 21 May 2022 20:50:50 -0700
Subject: [PATCH] Add SplitPane assignment

This, along with the plumbing included here, allows specifying
the destination of the split (now you can specify top/left, whereas
previously it was limited to right/bottom), as well as the size
of the split, and also whether the split targets the node at the
top level of the tab rather than the active pane--that is referred
to as full-width in tmux terminology.

https://github.com/wez/wezterm/issues/578
---
 bintree/src/lib.rs                            |  36 +++
 codec/src/lib.rs                              |   6 +-
 config/src/keyassignment.rs                   |  24 ++
 docs/changelog.md                             |   1 +
 .../lua/keyassignment/SplitHorizontal.md      |   1 +
 docs/config/lua/keyassignment/SplitPane.md    |  29 ++
 .../config/lua/keyassignment/SplitVertical.md |   1 +
 mux/src/domain.rs                             |   8 +-
 mux/src/lib.rs                                |   6 +-
 mux/src/tab.rs                                | 292 ++++++++++++++----
 wezterm-client/src/domain.rs                  |   8 +-
 wezterm-gui/src/termwindow/mod.rs             |  60 +++-
 wezterm-gui/src/termwindow/spawn.rs           |   4 +-
 wezterm-mux-server-impl/src/sessionhandler.rs |   2 +-
 wezterm/src/main.rs                           |  74 ++++-
 15 files changed, 459 insertions(+), 93 deletions(-)
 create mode 100644 docs/config/lua/keyassignment/SplitPane.md

diff --git a/bintree/src/lib.rs b/bintree/src/lib.rs
index cc98aa3c3..189ac28d6 100644
--- a/bintree/src/lib.rs
+++ b/bintree/src/lib.rs
@@ -189,6 +189,14 @@ impl<L, N> Tree<L, N> {
             path: Box::new(Path::Top),
         }
     }
+
+    pub fn num_leaves(&self) -> usize {
+        match self {
+            Self::Empty => 0,
+            Self::Leaf(_) => 1,
+            Self::Node { left, right, .. } => left.num_leaves() + right.num_leaves(),
+        }
+    }
 }
 
 impl<L, N> Cursor<L, N> {
@@ -313,6 +321,34 @@ impl<L, N> Cursor<L, N> {
         }
     }
 
+    pub fn split_node_and_insert_left(self, to_insert: L) -> Result<Self, Self> {
+        match *self.it {
+            Tree::Node { left, right, data } => Ok(Self {
+                it: Box::new(Tree::Node {
+                    data: None,
+                    right: Box::new(Tree::Node { left, right, data }),
+                    left: Box::new(Tree::Leaf(to_insert)),
+                }),
+                path: self.path,
+            }),
+            _ => Err(self),
+        }
+    }
+
+    pub fn split_node_and_insert_right(self, to_insert: L) -> Result<Self, Self> {
+        match *self.it {
+            Tree::Node { left, right, data } => Ok(Self {
+                it: Box::new(Tree::Node {
+                    data: None,
+                    left: Box::new(Tree::Node { left, right, data }),
+                    right: Box::new(Tree::Leaf(to_insert)),
+                }),
+                path: self.path,
+            }),
+            _ => Err(self),
+        }
+    }
+
     /// If the current position is a leaf, split it into a Node where
     /// the left side holds the current leaf value and the right side
     /// holds the provided `right` value.
diff --git a/codec/src/lib.rs b/codec/src/lib.rs
index 9419f057c..527f7cf2e 100644
--- a/codec/src/lib.rs
+++ b/codec/src/lib.rs
@@ -15,7 +15,7 @@ use anyhow::{bail, Context as _, Error};
 use mux::client::{ClientId, ClientInfo};
 use mux::pane::PaneId;
 use mux::renderable::{RenderableDimensions, StableCursorPosition};
-use mux::tab::{PaneNode, SerdeUrl, SplitDirection, TabId};
+use mux::tab::{PaneNode, SerdeUrl, SplitRequest, TabId};
 use mux::window::WindowId;
 use portable_pty::{CommandBuilder, PtySize};
 use rangeset::*;
@@ -416,7 +416,7 @@ macro_rules! pdu {
 /// The overall version of the codec.
 /// This must be bumped when backwards incompatible changes
 /// are made to the types and protocol.
-pub const CODEC_VERSION: usize = 22;
+pub const CODEC_VERSION: usize = 23;
 
 // Defines the Pdu enum.
 // Each struct has an explicit identifying number.
@@ -592,7 +592,7 @@ pub struct ListPanesResponse {
 #[derive(Deserialize, Serialize, PartialEq, Debug)]
 pub struct SplitPane {
     pub pane_id: PaneId,
-    pub direction: SplitDirection,
+    pub split_request: SplitRequest,
     pub command: Option<CommandBuilder>,
     pub command_dir: Option<String>,
     pub domain: config::keyassignment::SpawnTabDomain,
diff --git a/config/src/keyassignment.rs b/config/src/keyassignment.rs
index 3f6eab8da..d6b91616d 100644
--- a/config/src/keyassignment.rs
+++ b/config/src/keyassignment.rs
@@ -371,9 +371,33 @@ pub enum KeyAssignment {
 
     CopyMode(CopyModeAssignment),
     RotatePanes(RotationDirection),
+    SplitPane(SplitPane),
 }
 impl_lua_conversion_dynamic!(KeyAssignment);
 
+#[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)]
+pub struct SplitPane {
+    pub direction: PaneDirection,
+    #[dynamic(default)]
+    pub size: SplitSize,
+    #[dynamic(default)]
+    pub command: SpawnCommand,
+    #[dynamic(default)]
+    pub top_level: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)]
+pub enum SplitSize {
+    Cells(usize),
+    Percent(u8),
+}
+
+impl Default for SplitSize {
+    fn default() -> Self {
+        Self::Percent(50)
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)]
 pub enum RotationDirection {
     Clockwise,
diff --git a/docs/changelog.md b/docs/changelog.md
index 67716f8b9..6e6b5bb55 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -35,6 +35,7 @@ As features stabilize some brief notes about them will accumulate here.
 * [cell_width](config/lua/config/cell_width.md) option to adjust the horizontal spacing when the availble font stretches are insufficient. [#1979](https://github.com/wez/wezterm/issues/1979)
 * [min_scroll_bar_height](config/lua/config/min_scroll_bar_height.md) to control the minimum size of the scroll bar thumb [#1936](https://github.com/wez/wezterm/issues/1936)
 * [RotatePanes](config/lua/keyassignment/RotatePanes.md) key assignment for re-arranging the panes in a tab
+* [SplitPane](config/lua/keyassignment/SplitPane.md) key assignment that allows specifying the size and location of the split, as well as top-level (full width/height) splits. `wezterm cli split-pane --help` shows equivalent options you can use from the cli. [#578](https://github.com/wez/wezterm/issues/578)
 
 
 #### Updated
diff --git a/docs/config/lua/keyassignment/SplitHorizontal.md b/docs/config/lua/keyassignment/SplitHorizontal.md
index 6e216b2f3..35d013d4c 100644
--- a/docs/config/lua/keyassignment/SplitHorizontal.md
+++ b/docs/config/lua/keyassignment/SplitHorizontal.md
@@ -33,3 +33,4 @@ return {
 }
 ```
 
+See also: [SplitPane](SplitPane.md).
diff --git a/docs/config/lua/keyassignment/SplitPane.md b/docs/config/lua/keyassignment/SplitPane.md
new file mode 100644
index 000000000..abdddc3f2
--- /dev/null
+++ b/docs/config/lua/keyassignment/SplitPane.md
@@ -0,0 +1,29 @@
+# SplitPane
+
+*Since: nightly builds only*
+
+Splits the active pane in a particular direction, spawning a new command into the newly created pane.
+
+This assignment has a number of fields that control the overall action:
+
+* `direction` - can be one of `"Up"`, `"Down"`, `"Left"`, `"Right"`. Specifies where the new pane will end up. This field is required.
+* `size` - controls the size of the new pane. Can be `{Cells=10}` to specify eg: 10 cells or `{Percent=50}` to specify 50% of the available space.  If omitted, `{Percent=50}` is the default
+* `command` - the [SpawnCommand](../SpawnCommand.md) that specifies what program to launch into the new pane. If omitted, the [default_prog](../config/default_prog.md) is used
+* `top_level` - if set to `true`, rather than splitting the active pane, the split will be made at the root of the tab and effectively split the entire tab across the full extent possible.  The default is `false`.
+
+```lua
+local wezterm = require 'wezterm';
+
+return {
+  keys = {
+    -- This will create a new split and run the `top` program inside it
+    {key="%", mods="CTRL|SHIFT|ALT", action=wezterm.action{SplitPane={
+      direction="Left",
+      command={args={"top"}},
+      size={Percent=50},
+    }}},
+  }
+}
+```
+
+See also: [SplitHorizontal](SplitHorizontal.md), [SplitVertical](SplitVertical.md) and `wezterm cli split-pane --help`.
diff --git a/docs/config/lua/keyassignment/SplitVertical.md b/docs/config/lua/keyassignment/SplitVertical.md
index 4b50a2e1e..6ea61dc58 100644
--- a/docs/config/lua/keyassignment/SplitVertical.md
+++ b/docs/config/lua/keyassignment/SplitVertical.md
@@ -33,3 +33,4 @@ return {
 }
 ```
 
+See also: [SplitPane](SplitPane.md).
diff --git a/mux/src/domain.rs b/mux/src/domain.rs
index b0d7d1930..6de09afdd 100644
--- a/mux/src/domain.rs
+++ b/mux/src/domain.rs
@@ -7,7 +7,7 @@
 
 use crate::localpane::LocalPane;
 use crate::pane::{alloc_pane_id, Pane, PaneId};
-use crate::tab::{SplitDirection, Tab, TabId};
+use crate::tab::{SplitRequest, Tab, TabId};
 use crate::window::WindowId;
 use crate::Mux;
 use anyhow::{bail, Error};
@@ -59,7 +59,7 @@ pub trait Domain: Downcast {
         command_dir: Option<String>,
         tab: TabId,
         pane_id: PaneId,
-        direction: SplitDirection,
+        split_request: SplitRequest,
     ) -> anyhow::Result<Rc<dyn Pane>> {
         let mux = Mux::get().unwrap();
         let tab = match mux.get_tab(tab) {
@@ -76,7 +76,7 @@ pub trait Domain: Downcast {
             None => anyhow::bail!("invalid pane id {}", pane_id),
         };
 
-        let split_size = match tab.compute_split_size(pane_index, direction) {
+        let split_size = match tab.compute_split_size(pane_index, split_request) {
             Some(s) => s,
             None => anyhow::bail!("invalid pane index {}", pane_index),
         };
@@ -85,7 +85,7 @@ pub trait Domain: Downcast {
             .spawn_pane(split_size.second, command, command_dir)
             .await?;
 
-        tab.split_and_insert(pane_index, direction, Rc::clone(&pane))?;
+        tab.split_and_insert(pane_index, split_request, Rc::clone(&pane))?;
         Ok(pane)
     }
 
diff --git a/mux/src/lib.rs b/mux/src/lib.rs
index 60637389c..bc932c63c 100644
--- a/mux/src/lib.rs
+++ b/mux/src/lib.rs
@@ -1,6 +1,6 @@
 use crate::client::{ClientId, ClientInfo};
 use crate::pane::{Pane, PaneId};
-use crate::tab::{SplitDirection, Tab, TabId};
+use crate::tab::{SplitRequest, Tab, TabId};
 use crate::window::{Window, WindowId};
 use anyhow::{anyhow, Context, Error};
 use config::keyassignment::SpawnTabDomain;
@@ -941,7 +941,7 @@ impl Mux {
         &self,
         // TODO: disambiguate with TabId
         pane_id: PaneId,
-        direction: SplitDirection,
+        request: SplitRequest,
         command: Option<CommandBuilder>,
         command_dir: Option<String>,
         domain: config::keyassignment::SpawnTabDomain,
@@ -966,7 +966,7 @@ impl Mux {
         let cwd = self.resolve_cwd(command_dir, Some(Rc::clone(&current_pane)));
 
         let pane = domain
-            .split_pane(command, cwd, tab_id, pane_id, direction)
+            .split_pane(command, cwd, tab_id, pane_id, request)
             .await?;
         if let Some(config) = term_config {
             pane.set_config(config);
diff --git a/mux/src/tab.rs b/mux/src/tab.rs
index 065335762..890951d63 100644
--- a/mux/src/tab.rs
+++ b/mux/src/tab.rs
@@ -79,6 +79,41 @@ pub struct SplitDirectionAndSize {
     pub second: PtySize,
 }
 
+#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
+pub enum SplitSize {
+    Cells(usize),
+    Percent(u8),
+}
+
+impl Default for SplitSize {
+    fn default() -> Self {
+        Self::Percent(50)
+    }
+}
+
+#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
+pub struct SplitRequest {
+    pub direction: SplitDirection,
+    /// Whether the newly created item will be in the second part
+    /// of the split (right/bottom)
+    pub target_is_second: bool,
+    /// Split across the top of the tab rather than the active pane
+    pub top_level: bool,
+    /// The size of the new item
+    pub size: SplitSize,
+}
+
+impl Default for SplitRequest {
+    fn default() -> Self {
+        Self {
+            direction: SplitDirection::Horizontal,
+            target_is_second: true,
+            top_level: false,
+            size: SplitSize::default(),
+        }
+    }
+}
+
 impl SplitDirectionAndSize {
     fn top_of_second(&self) -> usize {
         match self.direction {
@@ -1443,36 +1478,74 @@ impl Tab {
     pub fn compute_split_size(
         &self,
         pane_index: usize,
-        direction: SplitDirection,
+        request: SplitRequest,
     ) -> Option<SplitDirectionAndSize> {
         let cell_dims = self.cell_dimensions();
 
+        fn split_dimension(dim: usize, request: SplitRequest) -> (usize, usize) {
+            let target_size = match request.size {
+                SplitSize::Cells(n) => n,
+                SplitSize::Percent(n) => (dim * (n as usize)) / 100,
+            }
+            .max(1);
+
+            let remain = dim.saturating_sub(target_size + 1);
+
+            if request.target_is_second {
+                (remain, target_size)
+            } else {
+                (target_size, remain)
+            }
+        }
+
+        if request.top_level {
+            let size = self.size.borrow().clone();
+
+            let ((width1, width2), (height1, height2)) = match request.direction {
+                SplitDirection::Horizontal => (
+                    split_dimension(size.cols as usize, request),
+                    (size.rows as usize, size.rows as usize),
+                ),
+                SplitDirection::Vertical => (
+                    (size.cols as usize, size.cols as usize),
+                    split_dimension(size.rows as usize, request),
+                ),
+            };
+
+            return Some(SplitDirectionAndSize {
+                direction: request.direction,
+                first: PtySize {
+                    rows: height1 as _,
+                    cols: width1 as _,
+                    pixel_height: cell_dims.pixel_height * height1 as u16,
+                    pixel_width: cell_dims.pixel_width * width1 as u16,
+                },
+                second: PtySize {
+                    rows: height2 as _,
+                    cols: width2 as _,
+                    pixel_height: cell_dims.pixel_height * height2 as u16,
+                    pixel_width: cell_dims.pixel_width * width2 as u16,
+                },
+            });
+        }
+
         // Ensure that we're not zoomed, otherwise we'll end up in
         // a bogus split state (https://github.com/wez/wezterm/issues/723)
         self.set_zoomed(false);
 
         self.iter_panes().iter().nth(pane_index).map(|pos| {
-            fn split_dimension(dim: usize) -> (usize, usize) {
-                let halved = dim / 2;
-                if halved * 2 == dim {
-                    // Was an even size; we need to allow 1 cell to render
-                    // the split UI, so make the newly created leaf slightly
-                    // smaller
-                    (halved, halved.saturating_sub(1))
-                } else {
-                    (halved, halved)
+            let ((width1, width2), (height1, height2)) = match request.direction {
+                SplitDirection::Horizontal => (
+                    split_dimension(pos.width, request),
+                    (pos.height, pos.height),
+                ),
+                SplitDirection::Vertical => {
+                    ((pos.width, pos.width), split_dimension(pos.height, request))
                 }
-            }
-
-            let ((width1, width2), (height1, height2)) = match direction {
-                SplitDirection::Horizontal => {
-                    (split_dimension(pos.width), (pos.height, pos.height))
-                }
-                SplitDirection::Vertical => ((pos.width, pos.width), split_dimension(pos.height)),
             };
 
             SplitDirectionAndSize {
-                direction,
+                direction: request.direction,
                 first: PtySize {
                     rows: height1 as _,
                     cols: width1 as _,
@@ -1496,7 +1569,7 @@ impl Tab {
     pub fn split_and_insert(
         &self,
         pane_index: usize,
-        direction: SplitDirection,
+        request: SplitRequest,
         pane: Rc<dyn Pane>,
     ) -> anyhow::Result<usize> {
         if self.zoomed.borrow().is_some() {
@@ -1505,10 +1578,11 @@ impl Tab {
 
         {
             let split_info = self
-                .compute_split_size(pane_index, direction)
+                .compute_split_size(pane_index, request)
                 .ok_or_else(|| {
                     anyhow::anyhow!("invalid pane_index {}; cannot split!", pane_index)
                 })?;
+
             let tab_size = *self.size.borrow();
             if split_info.first.rows == 0
                 || split_info.first.cols == 0
@@ -1529,9 +1603,53 @@ impl Tab {
                 anyhow::bail!("No space for split!");
             }
 
+            let needs_resize = if request.top_level {
+                self.pane.borrow().as_ref().unwrap().num_leaves() > 1
+            } else {
+                false
+            };
+
+            if needs_resize {
+                // Pre-emptively resize the tab contents down to
+                // match the target size; it's easier to reuse
+                // existing resize logic that way
+                if request.target_is_second {
+                    self.resize(split_info.first.clone());
+                } else {
+                    self.resize(split_info.second.clone());
+                }
+            }
+
             let mut root = self.pane.borrow_mut();
             let mut cursor = root.take().unwrap().cursor();
 
+            if request.top_level && !cursor.is_leaf() {
+                let result = if request.target_is_second {
+                    cursor.split_node_and_insert_right(Rc::clone(&pane))
+                } else {
+                    cursor.split_node_and_insert_left(Rc::clone(&pane))
+                };
+                cursor = match result {
+                    Ok(c) => {
+                        cursor = match c.assign_node(Some(split_info)) {
+                            Err(c) | Ok(c) => c,
+                        };
+
+                        root.replace(cursor.tree());
+
+                        let pane_index = if request.target_is_second {
+                            root.as_ref().unwrap().num_leaves().saturating_sub(1)
+                        } else {
+                            0
+                        };
+
+                        *self.active.borrow_mut() = pane_index;
+                        return Ok(pane_index);
+                    }
+                    Err(cursor) => cursor,
+                };
+            }
+
             match cursor.go_to_nth_leaf(pane_index) {
                 Ok(c) => cursor = c,
                 Err(c) => {
@@ -1542,10 +1660,18 @@ impl Tab {
 
             let existing_pane = Rc::clone(cursor.leaf_mut().unwrap());
 
-            existing_pane.resize(split_info.first)?;
-            pane.resize(split_info.second.clone())?;
+            let (pane1, pane2) = if request.target_is_second {
+                (existing_pane, pane)
+            } else {
+                (pane, existing_pane)
+            };
 
-            match cursor.split_leaf_and_insert_right(pane) {
+            pane1.resize(split_info.first)?;
+            pane2.resize(split_info.second.clone())?;
+
+            *cursor.leaf_mut().unwrap() = pane1;
+
+            match cursor.split_leaf_and_insert_right(pane2) {
                 Ok(c) => cursor = c,
                 Err(c) => {
                     root.replace(c.tree());
@@ -1559,13 +1685,19 @@ impl Tab {
                 Err(c) | Ok(c) => root.replace(c.tree()),
             };
 
-            *self.active.borrow_mut() = pane_index + 1;
+            if request.target_is_second {
+                *self.active.borrow_mut() = pane_index + 1;
+            }
         }
 
         log::debug!("split info after split: {:#?}", self.iter_splits());
         log::debug!("pane info after split: {:#?}", self.iter_panes());
 
-        Ok(pane_index + 1)
+        Ok(if request.target_is_second {
+            pane_index + 1
+        } else {
+            pane_index
+        })
     }
 }
 
@@ -1784,23 +1916,35 @@ mod test {
         assert_eq!(24, panes[0].height);
 
         assert!(tab
-            .compute_split_size(1, SplitDirection::Horizontal)
+            .compute_split_size(
+                1,
+                SplitRequest {
+                    direction: SplitDirection::Horizontal,
+                    ..Default::default()
+                }
+            )
             .is_none());
 
         let horz_size = tab
-            .compute_split_size(0, SplitDirection::Horizontal)
+            .compute_split_size(
+                0,
+                SplitRequest {
+                    direction: SplitDirection::Horizontal,
+                    ..Default::default()
+                },
+            )
             .unwrap();
         assert_eq!(
             horz_size,
             SplitDirectionAndSize {
                 direction: SplitDirection::Horizontal,
-                first: PtySize {
+                second: PtySize {
                     rows: 24,
                     cols: 40,
                     pixel_width: 400,
                     pixel_height: 600
                 },
-                second: PtySize {
+                first: PtySize {
                     rows: 24,
                     cols: 39,
                     pixel_width: 390,
@@ -1809,18 +1953,26 @@ mod test {
             }
         );
 
-        let vert_size = tab.compute_split_size(0, SplitDirection::Vertical).unwrap();
+        let vert_size = tab
+            .compute_split_size(
+                0,
+                SplitRequest {
+                    direction: SplitDirection::Vertical,
+                    ..Default::default()
+                },
+            )
+            .unwrap();
         assert_eq!(
             vert_size,
             SplitDirectionAndSize {
                 direction: SplitDirection::Vertical,
-                first: PtySize {
+                second: PtySize {
                     rows: 12,
                     cols: 80,
                     pixel_width: 800,
                     pixel_height: 300
                 },
-                second: PtySize {
+                first: PtySize {
                     rows: 11,
                     cols: 80,
                     pixel_width: 800,
@@ -1832,7 +1984,10 @@ mod test {
         let new_index = tab
             .split_and_insert(
                 0,
-                SplitDirection::Horizontal,
+                SplitRequest {
+                    direction: SplitDirection::Horizontal,
+                    ..Default::default()
+                },
                 FakePane::new(2, horz_size.second),
             )
             .unwrap();
@@ -1845,27 +2000,40 @@ mod test {
         assert_eq!(false, panes[0].is_active);
         assert_eq!(0, panes[0].left);
         assert_eq!(0, panes[0].top);
-        assert_eq!(40, panes[0].width);
+        assert_eq!(39, panes[0].width);
         assert_eq!(24, panes[0].height);
-        assert_eq!(400, panes[0].pixel_width);
+        assert_eq!(390, panes[0].pixel_width);
         assert_eq!(600, panes[0].pixel_height);
         assert_eq!(1, panes[0].pane.pane_id());
 
         assert_eq!(1, panes[1].index);
         assert_eq!(true, panes[1].is_active);
-        assert_eq!(41, panes[1].left);
+        assert_eq!(40, panes[1].left);
         assert_eq!(0, panes[1].top);
-        assert_eq!(39, panes[1].width);
+        assert_eq!(40, panes[1].width);
         assert_eq!(24, panes[1].height);
-        assert_eq!(390, panes[1].pixel_width);
+        assert_eq!(400, panes[1].pixel_width);
         assert_eq!(600, panes[1].pixel_height);
         assert_eq!(2, panes[1].pane.pane_id());
 
-        let vert_size = tab.compute_split_size(0, SplitDirection::Vertical).unwrap();
+        let vert_size = tab
+            .compute_split_size(
+                0,
+                SplitRequest {
+                    direction: SplitDirection::Vertical,
+                    ..Default::default()
+                },
+            )
+            .unwrap();
         let new_index = tab
             .split_and_insert(
                 0,
-                SplitDirection::Vertical,
+                SplitRequest {
+                    direction: SplitDirection::Vertical,
+                    top_level: false,
+                    target_is_second: true,
+                    size: Default::default(),
+                },
                 FakePane::new(3, vert_size.second),
             )
             .unwrap();
@@ -1878,47 +2046,47 @@ mod test {
         assert_eq!(false, panes[0].is_active);
         assert_eq!(0, panes[0].left);
         assert_eq!(0, panes[0].top);
-        assert_eq!(40, panes[0].width);
-        assert_eq!(12, panes[0].height);
-        assert_eq!(400, panes[0].pixel_width);
-        assert_eq!(300, panes[0].pixel_height);
+        assert_eq!(39, panes[0].width);
+        assert_eq!(11, panes[0].height);
+        assert_eq!(390, panes[0].pixel_width);
+        assert_eq!(275, panes[0].pixel_height);
         assert_eq!(1, panes[0].pane.pane_id());
 
         assert_eq!(1, panes[1].index);
         assert_eq!(true, panes[1].is_active);
         assert_eq!(0, panes[1].left);
-        assert_eq!(13, panes[1].top);
-        assert_eq!(40, panes[1].width);
-        assert_eq!(11, panes[1].height);
-        assert_eq!(400, panes[1].pixel_width);
-        assert_eq!(275, panes[1].pixel_height);
+        assert_eq!(12, panes[1].top);
+        assert_eq!(39, panes[1].width);
+        assert_eq!(12, panes[1].height);
+        assert_eq!(390, panes[1].pixel_width);
+        assert_eq!(300, panes[1].pixel_height);
         assert_eq!(3, panes[1].pane.pane_id());
 
         assert_eq!(2, panes[2].index);
         assert_eq!(false, panes[2].is_active);
-        assert_eq!(41, panes[2].left);
+        assert_eq!(40, panes[2].left);
         assert_eq!(0, panes[2].top);
-        assert_eq!(39, panes[2].width);
+        assert_eq!(40, panes[2].width);
         assert_eq!(24, panes[2].height);
-        assert_eq!(390, panes[2].pixel_width);
+        assert_eq!(400, panes[2].pixel_width);
         assert_eq!(600, panes[2].pixel_height);
         assert_eq!(2, panes[2].pane.pane_id());
 
         tab.resize_split_by(1, 1);
         let panes = tab.iter_panes();
-        assert_eq!(40, panes[0].width);
-        assert_eq!(13, panes[0].height);
-        assert_eq!(400, panes[0].pixel_width);
-        assert_eq!(325, panes[0].pixel_height);
+        assert_eq!(39, panes[0].width);
+        assert_eq!(12, panes[0].height);
+        assert_eq!(390, panes[0].pixel_width);
+        assert_eq!(300, panes[0].pixel_height);
 
-        assert_eq!(40, panes[1].width);
-        assert_eq!(10, panes[1].height);
-        assert_eq!(400, panes[1].pixel_width);
-        assert_eq!(250, panes[1].pixel_height);
+        assert_eq!(39, panes[1].width);
+        assert_eq!(11, panes[1].height);
+        assert_eq!(390, panes[1].pixel_width);
+        assert_eq!(275, panes[1].pixel_height);
 
-        assert_eq!(39, panes[2].width);
+        assert_eq!(40, panes[2].width);
         assert_eq!(24, panes[2].height);
-        assert_eq!(390, panes[2].pixel_width);
+        assert_eq!(400, panes[2].pixel_width);
         assert_eq!(600, panes[2].pixel_height);
     }
 }
diff --git a/wezterm-client/src/domain.rs b/wezterm-client/src/domain.rs
index 327295e97..3d19c28cb 100644
--- a/wezterm-client/src/domain.rs
+++ b/wezterm-client/src/domain.rs
@@ -8,7 +8,7 @@ use config::{SshDomain, TlsDomainClient, UnixDomain};
 use mux::connui::{ConnectionUI, ConnectionUIParams};
 use mux::domain::{alloc_domain_id, Domain, DomainId, DomainState};
 use mux::pane::{Pane, PaneId};
-use mux::tab::{SplitDirection, Tab, TabId};
+use mux::tab::{SplitRequest, Tab, TabId};
 use mux::window::WindowId;
 use mux::{Mux, MuxNotification};
 use portable_pty::{CommandBuilder, PtySize};
@@ -541,7 +541,7 @@ impl Domain for ClientDomain {
         command_dir: Option<String>,
         tab_id: TabId,
         pane_id: PaneId,
-        direction: SplitDirection,
+        split_request: SplitRequest,
     ) -> anyhow::Result<Rc<dyn Pane>> {
         let inner = self
             .inner()
@@ -564,7 +564,7 @@ impl Domain for ClientDomain {
             .split_pane(SplitPane {
                 domain: SpawnTabDomain::CurrentPaneDomain,
                 pane_id: pane.remote_pane_id,
-                direction,
+                split_request,
                 command,
                 command_dir,
             })
@@ -587,7 +587,7 @@ impl Domain for ClientDomain {
             None => anyhow::bail!("invalid pane id {}", pane_id),
         };
 
-        tab.split_and_insert(pane_index, direction, Rc::clone(&pane))
+        tab.split_and_insert(pane_index, split_request, Rc::clone(&pane))
             .ok();
 
         mux.add_pane(&pane)?;
diff --git a/wezterm-gui/src/termwindow/mod.rs b/wezterm-gui/src/termwindow/mod.rs
index 03eca4775..cd2dd1139 100644
--- a/wezterm-gui/src/termwindow/mod.rs
+++ b/wezterm-gui/src/termwindow/mod.rs
@@ -22,8 +22,8 @@ use ::wezterm_term::input::{ClickPosition, MouseButton as TMB};
 use ::window::*;
 use anyhow::{anyhow, ensure, Context};
 use config::keyassignment::{
-    ClipboardCopyDestination, ClipboardPasteSource, KeyAssignment, Pattern, QuickSelectArguments,
-    RotationDirection, SpawnCommand,
+    ClipboardCopyDestination, ClipboardPasteSource, KeyAssignment, PaneDirection, Pattern,
+    QuickSelectArguments, RotationDirection, SpawnCommand, SplitSize,
 };
 use config::{
     configuration, AudibleBell, ConfigHandle, Dimension, DimensionContext, GradientOrientation,
@@ -32,7 +32,10 @@ use config::{
 use mlua::{FromLua, UserData, UserDataFields};
 use mux::pane::{CloseReason, Pane, PaneId, Pattern as MuxPattern};
 use mux::renderable::RenderableDimensions;
-use mux::tab::{PositionedPane, PositionedSplit, SplitDirection, Tab, TabId};
+use mux::tab::{
+    PositionedPane, PositionedSplit, SplitDirection, SplitRequest, SplitSize as MuxSplitSize, Tab,
+    TabId,
+};
 use mux::window::WindowId as MuxWindowId;
 use mux::{Mux, MuxNotification};
 use portable_pty::PtySize;
@@ -2093,11 +2096,27 @@ impl TermWindow {
             }
             SplitHorizontal(spawn) => {
                 log::trace!("SplitHorizontal {:?}", spawn);
-                self.spawn_command(spawn, SpawnWhere::SplitPane(SplitDirection::Horizontal));
+                self.spawn_command(
+                    spawn,
+                    SpawnWhere::SplitPane(SplitRequest {
+                        direction: SplitDirection::Horizontal,
+                        target_is_second: true,
+                        size: MuxSplitSize::Percent(50),
+                        top_level: false,
+                    }),
+                );
             }
             SplitVertical(spawn) => {
                 log::trace!("SplitVertical {:?}", spawn);
-                self.spawn_command(spawn, SpawnWhere::SplitPane(SplitDirection::Vertical));
+                self.spawn_command(
+                    spawn,
+                    SpawnWhere::SplitPane(SplitRequest {
+                        direction: SplitDirection::Vertical,
+                        target_is_second: true,
+                        size: MuxSplitSize::Percent(50),
+                        top_level: false,
+                    }),
+                );
             }
             ToggleFullScreen => {
                 self.window.as_ref().unwrap().toggle_fullscreen();
@@ -2488,6 +2507,37 @@ impl TermWindow {
                     RotationDirection::CounterClockwise => tab.rotate_counter_clockwise(),
                 }
             }
+            SplitPane(split) => {
+                log::trace!("SplitPane {:?}", split);
+                self.spawn_command(
+                    &split.command,
+                    SpawnWhere::SplitPane(SplitRequest {
+                        direction: match split.direction {
+                            PaneDirection::Down | PaneDirection::Up => SplitDirection::Vertical,
+                            PaneDirection::Left | PaneDirection::Right => {
+                                SplitDirection::Horizontal
+                            }
+                            PaneDirection::Next | PaneDirection::Prev => {
+                                log::error!(
+                                    "Invalid direction {:?} for SplitPane",
+                                    split.direction
+                                );
+                                return Ok(());
+                            }
+                        },
+                        target_is_second: match split.direction {
+                            PaneDirection::Down | PaneDirection::Right => true,
+                            PaneDirection::Up | PaneDirection::Left => false,
+                            PaneDirection::Next | PaneDirection::Prev => unreachable!(),
+                        },
+                        size: match split.size {
+                            SplitSize::Percent(n) => MuxSplitSize::Percent(n),
+                            SplitSize::Cells(n) => MuxSplitSize::Cells(n),
+                        },
+                        top_level: split.top_level,
+                    }),
+                );
+            }
         };
         Ok(())
     }
diff --git a/wezterm-gui/src/termwindow/spawn.rs b/wezterm-gui/src/termwindow/spawn.rs
index ef2a53fd8..204968115 100644
--- a/wezterm-gui/src/termwindow/spawn.rs
+++ b/wezterm-gui/src/termwindow/spawn.rs
@@ -3,7 +3,7 @@ use anyhow::{anyhow, bail, Context};
 use config::keyassignment::{SpawnCommand, SpawnTabDomain};
 use config::TermConfig;
 use mux::activity::Activity;
-use mux::tab::SplitDirection;
+use mux::tab::SplitRequest;
 use mux::Mux;
 use portable_pty::{CommandBuilder, PtySize};
 use std::sync::Arc;
@@ -12,7 +12,7 @@ use std::sync::Arc;
 pub enum SpawnWhere {
     NewWindow,
     NewTab,
-    SplitPane(SplitDirection),
+    SplitPane(SplitRequest),
 }
 
 impl super::TermWindow {
diff --git a/wezterm-mux-server-impl/src/sessionhandler.rs b/wezterm-mux-server-impl/src/sessionhandler.rs
index e7d27cbee..731c9472e 100644
--- a/wezterm-mux-server-impl/src/sessionhandler.rs
+++ b/wezterm-mux-server-impl/src/sessionhandler.rs
@@ -722,7 +722,7 @@ async fn split_pane(split: SplitPane, client_id: Option<Arc<ClientId>>) -> anyho
     let (pane, size) = mux
         .split_pane(
             split.pane_id,
-            split.direction,
+            split.split_request,
             split.command,
             split.command_dir,
             split.domain,
diff --git a/wezterm/src/main.rs b/wezterm/src/main.rs
index 5c5cf4170..22749f47e 100644
--- a/wezterm/src/main.rs
+++ b/wezterm/src/main.rs
@@ -4,7 +4,7 @@ use config::keyassignment::SpawnTabDomain;
 use config::wezterm_version;
 use mux::activity::Activity;
 use mux::pane::PaneId;
-use mux::tab::SplitDirection;
+use mux::tab::{SplitDirection, SplitRequest, SplitSize};
 use mux::window::WindowId;
 use mux::Mux;
 use portable_pty::cmdbuilder::CommandBuilder;
@@ -160,6 +160,7 @@ enum CliSubCommand {
 
     #[structopt(
         name = "split-pane",
+        rename_all = "kebab",
         about = "split the current pane.
 Outputs the pane-id for the newly created pane on success"
     )]
@@ -167,16 +168,47 @@ Outputs the pane-id for the newly created pane on success"
         /// Specify the pane that should be split.
         /// The default is to use the current pane based on the
         /// environment variable WEZTERM_PANE.
-        #[structopt(long = "pane-id")]
+        #[structopt(long)]
         pane_id: Option<PaneId>,
 
         /// Split horizontally rather than vertically
-        #[structopt(long = "horizontal")]
+        #[structopt(long, conflicts_with_all=&["left", "right", "top", "bottom"])]
         horizontal: bool,
 
+        /// Split horizontally, with the new pane on the left
+        #[structopt(long, conflicts_with_all=&["right", "top", "bottom"])]
+        left: bool,
+
+        /// Split horizontally, with the new pane on the right
+        #[structopt(long, conflicts_with_all=&["left", "top", "bottom"])]
+        right: bool,
+
+        /// Split vertically, with the new pane on the top
+        #[structopt(long, conflicts_with_all=&["left", "right", "bottom"])]
+        top: bool,
+
+        /// Split vertically, with the new pane on the bottom
+        #[structopt(long, conflicts_with_all=&["left", "right", "top"])]
+        bottom: bool,
+
+        /// Rather than splitting the active pane, split the entire
+        /// window.
+        #[structopt(long)]
+        top_level: bool,
+
+        /// The number of cells that the new split should have.
+        /// If omitted, 50% of the available space is used.
+        #[structopt(long)]
+        cells: Option<usize>,
+
+        /// Specify the number of cells that the new split should
+        /// have, expressed as a percentage of the available space.
+        #[structopt(long, conflicts_with = "cells")]
+        percent: Option<u8>,
+
         /// Specify the current working directory for the initially
         /// spawned program
-        #[structopt(long = "cwd", parse(from_os_str))]
+        #[structopt(long, parse(from_os_str))]
         cwd: Option<OsString>,
 
         /// Instead of executing your shell, run PROG.
@@ -743,17 +775,41 @@ async fn run_cli_async(config: config::ConfigHandle, cli: CliCommand) -> anyhow:
             cwd,
             prog,
             horizontal,
+            left,
+            right,
+            top,
+            bottom,
+            top_level,
+            cells,
+            percent,
         } => {
             let pane_id = resolve_pane_id(&client, pane_id).await?;
 
+            let direction = if left || right || horizontal {
+                SplitDirection::Horizontal
+            } else if top || bottom {
+                SplitDirection::Vertical
+            } else {
+                anyhow::bail!("impossible combination of args");
+            };
+            let target_is_second = right || bottom;
+            let size = match (cells, percent) {
+                (Some(c), _) => SplitSize::Cells(c),
+                (_, Some(p)) => SplitSize::Percent(p),
+                (None, None) => SplitSize::Percent(50),
+            };
+
+            let split_request = SplitRequest {
+                direction,
+                target_is_second,
+                size,
+                top_level,
+            };
+
             let spawned = client
                 .split_pane(codec::SplitPane {
                     pane_id,
-                    direction: if horizontal {
-                        SplitDirection::Horizontal
-                    } else {
-                        SplitDirection::Vertical
-                    },
+                    split_request,
                     domain: config::keyassignment::SpawnTabDomain::CurrentPaneDomain,
                     command: if prog.is_empty() {
                         None