From 161c633b42c5535b1c649487a330b23a9fa11626 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Mon, 23 Mar 2026 12:33:04 +1300 Subject: [PATCH] Add split-wezterm: split WezTerm pane into a rows x cols grid Splits the current pane into evenly-sized grid using wezterm cli split-pane. Supports optional command per pane, dryrun mode, and auto-detects native or Flatpak WezTerm CLI. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/split-wezterm | 146 ++++++++++++++++++++++++++++++++++++ specs/split-wezterm.spec.md | 89 ++++++++++++++++++++++ tests/test-split-wezterm.sh | 67 +++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100755 scripts/split-wezterm create mode 100644 specs/split-wezterm.spec.md create mode 100755 tests/test-split-wezterm.sh diff --git a/scripts/split-wezterm b/scripts/split-wezterm new file mode 100755 index 0000000..12d20ae --- /dev/null +++ b/scripts/split-wezterm @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +# split-wezterm — Split the current WezTerm pane into a rows x cols grid + +DRYRUN=false + +usage() { + cat <<'EOF' +Usage: split-wezterm [--dryrun|-n] [--] [command...] + +Split the current WezTerm pane into an evenly-sized grid. + +Arguments: + rows Number of rows (1-10) + cols Number of columns (1-10) + command... Optional command to run in each new pane + +Flags: + --dryrun, -n Preview commands without executing + --help, -h Show this help + +Examples: + split-wezterm 3 4 + split-wezterm 2 2 -- htop + split-wezterm --dryrun 3 4 -- claude +EOF +} + +die() { echo "Error: $*" >&2; exit 1; } + +# Parse flags +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) usage; exit 0 ;; + --dryrun|-n) DRYRUN=true; shift ;; + --) shift; break ;; + -*) die "Unknown flag: $1" ;; + *) break ;; + esac +done + +[[ $# -lt 2 ]] && { usage >&2; exit 1; } + +ROWS="$1"; shift +COLS="$1"; shift +# Skip -- separator if present +[[ "${1:-}" == "--" ]] && shift +CMD=("$@") + +# Validate dimensions +[[ "$ROWS" =~ ^[0-9]+$ ]] || die "rows must be a positive integer, got: $ROWS" +[[ "$COLS" =~ ^[0-9]+$ ]] || die "cols must be a positive integer, got: $COLS" +(( ROWS >= 1 && ROWS <= 10 )) || die "rows must be 1-10, got: $ROWS" +(( COLS >= 1 && COLS <= 10 )) || die "cols must be 1-10, got: $COLS" + +if (( ROWS == 1 && COLS == 1 )); then + echo "Already a single pane, nothing to do." + exit 0 +fi + +# Detect WezTerm CLI +detect_cli() { + if command -v wezterm &>/dev/null; then + echo "wezterm" + elif flatpak info org.wezfurlong.wezterm &>/dev/null 2>&1; then + echo "flatpak run org.wezfurlong.wezterm" + else + die "WezTerm CLI not found. Install WezTerm or ensure it's in PATH." + fi +} + +WEZTERM_CLI=$(detect_cli) + +# Get the starting pane ID +if [[ -n "${WEZTERM_PANE:-}" ]]; then + START_PANE="$WEZTERM_PANE" +else + # Try to find the active pane from wezterm cli list + START_PANE=$($WEZTERM_CLI cli list --format json 2>/dev/null \ + | python3 -c "import sys,json; panes=json.load(sys.stdin); print(panes[0]['pane_id'])" 2>/dev/null) \ + || die "Cannot determine current pane. Set WEZTERM_PANE or run from inside WezTerm." +fi + +# Run or preview a wezterm cli command; capture stdout (the new pane ID) +run_cli() { + if $DRYRUN; then + echo "[dryrun] $WEZTERM_CLI cli $*" >&2 + echo "dry-$$-$RANDOM" # fake pane ID for dryrun tracking + else + $WEZTERM_CLI cli "$@" + fi +} + +# Split a pane into N equal parts along an axis. +# Usage: split_axis +# direction_flag: --bottom (rows) or --horizontal (cols) +# Populates the named array with all pane IDs (original + new) +split_axis() { + local pane_id="$1" count="$2" direction="$3" result_var="$4" + local -a pane_ids=("$pane_id") + + local i percent new_pane + for (( i = 0; i < count - 1; i++ )); do + percent=$(( (count - 1 - i) * 100 / (count - i) )) + local cmd_args=(split-pane --pane-id "$pane_id" "$direction" --percent "$percent") + if [[ ${#CMD[@]} -gt 0 ]]; then + cmd_args+=(-- "${CMD[@]}") + fi + new_pane=$(run_cli "${cmd_args[@]}") + pane_id="$new_pane" + pane_ids+=("$new_pane") + done + + # Copy results to the named variable + eval "$result_var=(\"\${pane_ids[@]}\")" +} + +# Step 1: Split into rows +split_axis "$START_PANE" "$ROWS" "--bottom" row_panes + +# Step 2: Split each row into columns +for row_pane in "${row_panes[@]}"; do + if (( COLS > 1 )); then + split_axis "$row_pane" "$COLS" "--horizontal" _col_panes + fi +done + +# Step 3: If a command was given, run it in the original pane too +if [[ ${#CMD[@]} -gt 0 ]]; then + cmd_text="${CMD[*]}" + if $DRYRUN; then + echo "[dryrun] $WEZTERM_CLI cli send-text --pane-id $START_PANE --no-paste -- \"$cmd_text\"" >&2 + else + # Send the command text followed by enter + $WEZTERM_CLI cli send-text --pane-id "$START_PANE" --no-paste -- "$cmd_text +" + fi +fi + +TOTAL=$(( ROWS * COLS )) +if $DRYRUN; then + echo "[dryrun] Would create ${ROWS}x${COLS} grid ($TOTAL panes)" +else + echo "Created ${ROWS}x${COLS} grid ($TOTAL panes)" +fi diff --git a/specs/split-wezterm.spec.md b/specs/split-wezterm.spec.md new file mode 100644 index 0000000..df723e4 --- /dev/null +++ b/specs/split-wezterm.spec.md @@ -0,0 +1,89 @@ +# split-wezterm — OpenSpec + +## Purpose + +Split the current WezTerm pane into an evenly-sized grid of rows × columns, optionally running a command in each new pane. + +## Usage + +``` +split-wezterm [--] [command...] +split-wezterm --help +split-wezterm --dryrun [--] [command...] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `rows` | Yes | Number of rows (1-10) | +| `cols` | Yes | Number of columns (1-10) | +| `command...` | No | Command to run in each new pane. If omitted, panes open the default shell. | + +### Flags + +| Flag | Description | +|------|-------------| +| `--help`, `-h` | Show usage information | +| `--dryrun`, `-n` | Preview the split-pane commands without executing them | + +## Behaviour + +1. Validate that `rows` and `cols` are integers between 1 and 10 +2. Detect the WezTerm CLI binary (`wezterm` or `flatpak run org.wezfurlong.wezterm`) +3. Get the current pane ID from `$WEZTERM_PANE` or fall back to `wezterm cli list` to find the active pane +4. Create the grid using the following algorithm: + a. **Create rows first:** Split the original pane vertically (rows-1) times. Each split uses `--bottom --percent P` where P is calculated to produce equal-height rows. The first split takes `(rows-1)/rows` as the bottom percent, the next takes `(rows-2)/(rows-1)`, etc. + b. **Collect row pane IDs:** The original pane becomes row 0. Each `split-pane --bottom` returns the new pane ID. + c. **Create columns:** For each row pane, split horizontally (cols-1) times using the same proportional math for equal-width columns. + d. **Run command:** If a command was specified, pass it as the program argument to each `split-pane` call. The first pane (original) doesn't get split — if a command is specified, use `wezterm cli send-text` to run it there. +5. Output a summary: "Created {rows}x{cols} grid ({total} panes)" + +### Proportional splitting math + +To split a pane into N equal parts: +- Split i (0-indexed, i=0..N-2): `percent = (N - 1 - i) * 100 / (N - i)` +- This ensures each resulting section is 1/N of the original. + +Example for 3 splits: percent = 66, 50 (producing 33/33/33 split). + +## Dryrun behaviour + +In `--dryrun` mode, print each `wezterm cli split-pane` command that would be executed, prefixed with `[dryrun]`. Do not execute any commands. Still validate inputs and detect the CLI binary. + +Example: +``` +[dryrun] wezterm cli split-pane --pane-id 0 --bottom --percent 66 +[dryrun] wezterm cli split-pane --pane-id 0 --bottom --percent 50 +[dryrun] wezterm cli split-pane --pane-id 0 --horizontal --percent 75 +... +[dryrun] Would create 3x4 grid (12 panes) +``` + +## Edge cases + +- `1 1` — No splits needed. Print "Already a single pane, nothing to do." and exit 0. +- `1 N` — Only horizontal splits (columns only, no row splits). +- `N 1` — Only vertical splits (rows only, no column splits). +- No WezTerm CLI available — exit 1 with error message. +- `$WEZTERM_PANE` not set and no active pane found — exit 1 with error. +- Non-integer or out-of-range arguments — exit 1 with usage hint. + +## Examples + +```bash +# Split into 3 rows x 4 columns +split-wezterm 3 4 + +# Split into 2x2 grid, each pane running htop +split-wezterm 2 2 -- htop + +# Split into 2x3 grid, each running claude with a profile +split-wezterm 2 3 -- claude-with-profiles + +# Preview what would happen +split-wezterm --dryrun 3 4 + +# Single row, 3 columns +split-wezterm 1 3 +``` diff --git a/tests/test-split-wezterm.sh b/tests/test-split-wezterm.sh new file mode 100755 index 0000000..0a466db --- /dev/null +++ b/tests/test-split-wezterm.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/scripts/split-wezterm" +PASS=0; FAIL=0 + +pass() { echo -e "\e[32m PASS\e[0m $1"; (( PASS++ )); } +fail() { echo -e "\e[31m FAIL\e[0m $1: $2"; (( FAIL++ )); } + +assert_exit() { + local desc="$1" expected="$2"; shift 2 + local actual + "$@" >/dev/null 2>&1 && actual=0 || actual=$? + if [[ "$actual" == "$expected" ]]; then pass "$desc"; else fail "$desc" "expected exit $expected, got $actual"; fi +} + +assert_output_contains() { + local desc="$1" pattern="$2"; shift 2 + local output + output=$("$@" 2>&1) || true + if echo "$output" | grep -qF "$pattern"; then pass "$desc"; else fail "$desc" "output missing: $pattern"; fi +} + +assert_line_count() { + local desc="$1" expected="$2"; shift 2 + local count + count=$("$@" 2>&1 | grep -c '^\[dryrun\]') || true + if [[ "$count" == "$expected" ]]; then pass "$desc"; else fail "$desc" "expected $expected dryrun lines, got $count"; fi +} + +export WEZTERM_PANE=0 + +echo "=== split-wezterm tests ===" + +# Help +assert_exit "help flag exits 0" 0 "$SCRIPT" --help +assert_output_contains "help shows usage" "Usage:" "$SCRIPT" --help + +# Validation +assert_exit "no args exits 1" 1 "$SCRIPT" +assert_exit "one arg exits 1" 1 "$SCRIPT" 3 +assert_exit "zero rows exits 1" 1 "$SCRIPT" 0 3 +assert_exit "rows > 10 exits 1" 1 "$SCRIPT" 11 3 +assert_exit "non-integer rows exits 1" 1 "$SCRIPT" abc 3 + +# 1x1 — no-op +assert_exit "1x1 exits 0" 0 "$SCRIPT" 1 1 +assert_output_contains "1x1 says nothing to do" "nothing to do" "$SCRIPT" 1 1 + +# Dryrun counts (each split-pane = 1 dryrun line + 1 summary line) +# 2x1 = 1 row split + 1 summary = 2 lines +assert_line_count "2x1 produces 1 split" 2 "$SCRIPT" --dryrun 2 1 +# 1x3 = 2 col splits + 1 summary = 3 lines +assert_line_count "1x3 produces 2 splits" 3 "$SCRIPT" --dryrun 1 3 +# 2x2 = 1 row + 2 col + 1 summary = 4 lines +assert_line_count "2x2 produces 3 splits" 4 "$SCRIPT" --dryrun 2 2 +# 3x4 = 2 row + 9 col + 1 summary = 12 lines +assert_line_count "3x4 produces 11 splits" 12 "$SCRIPT" --dryrun 3 4 +# 3x4 with command = 11 splits + 1 send-text + 1 summary = 13 lines +assert_line_count "3x4 with cmd produces 13 lines" 13 "$SCRIPT" --dryrun 3 4 -- echo hi + +# Summary line +assert_output_contains "dryrun summary" "Would create 3x4 grid (12 panes)" "$SCRIPT" --dryrun 3 4 + +echo "" +echo "Results: $PASS passed, $FAIL failed" +(( FAIL == 0 ))