Files
tai/mplex.py
T

6849 lines
271 KiB
Python

#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
import argparse
import asyncio
import base64
import codecs
import collections
import contextlib
import difflib
import getpass
import glob
import hashlib
import importlib.util
import hmac
import html
import io
import itertools
import json
import operator
import os
import queue
import random
import re
import shutil
import shlex
import signal
import socket
import sqlite3
import ssl
import struct
import subprocess
import sys
import tempfile
import textwrap
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import weakref
import zipfile
from datetime import datetime, timedelta, timezone
try:
import readline
except ImportError:
readline = None
try:
import fcntl
except ImportError:
fcntl = None
try:
import pty
import select
import termios
import tty
except ImportError:
pty = select = termios = tty = None
PLEXER_DIR_NAME = "plexer"
PLEXER_PANE_ENV = "TAI_PLEXER_PANE"
PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET"
PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE"
PLEXER_RECV_MAX = 1 << 20
def plexer_dir(home):
path = os.path.join(home, PLEXER_DIR_NAME)
os.makedirs(path, exist_ok=True)
try:
os.chmod(path, 0o700)
except OSError:
pass
return path
class PlexerBackend:
def run(self):
raise NotImplementedError
# --------------------------------------------------------------------------- #
# Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns
# every pty, window layout and scrollback; clients attach over a Unix socket
# and receive finished frames. Key bindings, command names and option names
# follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`),
# and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md.
# --------------------------------------------------------------------------- #
MPLEX_SOCKET_NAME = "server.sock"
MPLEX_LOCK_NAME = "server.lock"
MPLEX_LOG_NAME = "server.log"
MPLEX_CONFIG_NAME = "mplex.toml"
MPLEX_MAGIC = b"\x01"
MPLEX_FRAME_MAX = 1 << 22
MPLEX_CLIENT_OUT_MAX = 1 << 22
MPLEX_PANE_IN_MAX = 1 << 22
MPLEX_MAX_SESSIONS = 64
MPLEX_MAX_WINDOWS = 256
MPLEX_MAX_PANES = 256
MPLEX_MAX_CLIENTS = 32
MPLEX_MESSAGES_MAX = 200
MPLEX_HISTORY_CAP = 200000
MPLEX_BUFFERS_CAP = 100
MPLEX_PROMPT_HISTORY_CAP = 100
MPLEX_COMMAND_DEPTH_MAX = 16
MPLEX_SHELL_TIMEOUT = 5.0
MPLEX_SHELL_OUTPUT_MAX = 65536
MPLEX_PASTE_MAX = 1 << 22
MPLEX_RENDER_INTERVAL = 0.012
MPLEX_REAP_KILL_AFTER = 2.0
MPLEX_IDLE_EXIT = 15.0
MPLEX_LOOP_ERRORS_MAX = 50
MPLEX_LOG_MAX = 1 << 24
MPLEX_CONFIG_MAX = 1 << 20
MPLEX_UNSUPPORTED = ("display-menu", "display-popup", "customize-mode", "lock-server", "lock-session", "lock-client", "pipe-pane", "join-pane", "move-pane", "link-window", "unlink-window", "respawn-pane", "respawn-window", "resize-window", "set-hook", "show-hooks", "wait-for", "server-access", "choose-tree-mouse")
# (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings.
MPLEX_DEFAULT_KEYS = (
('copy-mode', 'Escape', 'send-keys -X cancel', False, ''),
('copy-mode', 'Space', 'send-keys -X page-down', False, ''),
('copy-mode', ',', 'send-keys -X jump-reverse', False, ''),
('copy-mode', ';', 'send-keys -X jump-again', False, ''),
('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''),
('copy-mode', 'N', 'send-keys -X search-reverse', False, ''),
('copy-mode', 'P', 'send-keys -X toggle-position', False, ''),
('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''),
('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''),
('copy-mode', 'X', 'send-keys -X set-mark', False, ''),
('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''),
('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''),
('copy-mode', 'n', 'send-keys -X search-again', False, ''),
('copy-mode', 'q', 'send-keys -X cancel', False, ''),
('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''),
('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''),
('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''),
('copy-mode', 'End', 'send-keys -X end-of-line', False, ''),
('copy-mode', 'NPage', 'send-keys -X page-down', False, ''),
('copy-mode', 'PPage', 'send-keys -X page-up', False, ''),
('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''),
('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''),
('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''),
('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''),
('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode', 'M-<', 'send-keys -X history-top', False, ''),
('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''),
('copy-mode', 'M-R', 'send-keys -X top-line', False, ''),
('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''),
('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''),
('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''),
('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''),
('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''),
('copy-mode', 'M-v', 'send-keys -X page-up', False, ''),
('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''),
('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''),
('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''),
('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''),
('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''),
('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''),
('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''),
('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''),
('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''),
('copy-mode', 'C-c', 'send-keys -X cancel', False, ''),
('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''),
('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''),
('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''),
('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''),
('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''),
('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''),
('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''),
('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''),
('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''),
('copy-mode', 'C-v', 'send-keys -X page-down', False, ''),
('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''),
('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''),
('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''),
('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''),
('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''),
('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''),
('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''),
('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''),
('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''),
('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''),
('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''),
('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''),
('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''),
('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''),
('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''),
('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''),
('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''),
('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''),
('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''),
('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''),
('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''),
('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''),
('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''),
('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''),
('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''),
('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''),
('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''),
('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''),
('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''),
('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''),
('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''),
('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''),
('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''),
('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''),
('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''),
('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''),
('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''),
('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''),
('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''),
('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''),
('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''),
('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''),
('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''),
('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''),
('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''),
('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''),
('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''),
('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''),
('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''),
('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''),
('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''),
('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''),
('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''),
('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''),
('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''),
('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''),
('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''),
('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''),
('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''),
('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''),
('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''),
('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''),
('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''),
('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''),
('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''),
('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''),
('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''),
('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''),
('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''),
('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''),
('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''),
('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''),
('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''),
('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''),
('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''),
('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''),
('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''),
('prefix', 'Space', 'next-layout', False, 'Select next layout'),
('prefix', '!', 'break-pane', False, 'Break pane to a new window'),
('prefix', '"', 'split-window', False, 'Split window vertically'),
('prefix', '#', 'list-buffers', False, 'List all paste buffers'),
('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'),
('prefix', '%', 'split-window -h', False, 'Split window horizontally'),
('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'),
('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'),
('prefix', '(', 'switch-client -p', False, 'Switch to previous client'),
('prefix', ')', 'switch-client -n', False, 'Switch to next client'),
('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'),
('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'),
('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'),
('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'),
('prefix', '0', 'select-window -t :=0', False, 'Select window 0'),
('prefix', '1', 'select-window -t :=1', False, 'Select window 1'),
('prefix', '2', 'select-window -t :=2', False, 'Select window 2'),
('prefix', '3', 'select-window -t :=3', False, 'Select window 3'),
('prefix', '4', 'select-window -t :=4', False, 'Select window 4'),
('prefix', '5', 'select-window -t :=5', False, 'Select window 5'),
('prefix', '6', 'select-window -t :=6', False, 'Select window 6'),
('prefix', '7', 'select-window -t :=7', False, 'Select window 7'),
('prefix', '8', 'select-window -t :=8', False, 'Select window 8'),
('prefix', '9', 'select-window -t :=9', False, 'Select window 9'),
('prefix', ':', 'command-prompt', False, 'Prompt for a command'),
('prefix', ';', 'last-pane', False, 'Move to the previously active pane'),
('prefix', '<', 'display-menu', False, 'Display window menu'),
('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'),
('prefix', '>', 'display-menu', False, 'Display pane menu'),
('prefix', '?', 'list-keys -N', False, 'List key bindings'),
('prefix', 'C', 'customize-mode -Z', False, 'Customize options'),
('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'),
('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'),
('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'),
('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'),
('prefix', '[', 'copy-mode', False, 'Enter copy mode'),
('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'),
('prefix', 'c', 'new-window', False, 'Create a new window'),
('prefix', 'd', 'detach-client', False, 'Detach the current client'),
('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'),
('prefix', 'i', 'display-message', False, 'Display window information'),
('prefix', 'l', 'last-window', False, 'Select the previously current window'),
('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'),
('prefix', 'n', 'next-window', False, 'Select the next window'),
('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'),
('prefix', 'p', 'previous-window', False, 'Select the previous window'),
('prefix', 'q', 'display-panes', False, 'Display pane numbers'),
('prefix', 'r', 'refresh-client', False, 'Redraw the current client'),
('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'),
('prefix', 't', 'clock-mode', False, 'Show a clock'),
('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'),
('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'),
('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'),
('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'),
('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'),
('prefix', '~', 'show-messages', False, 'Show messages'),
('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'),
('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'),
('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'),
('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'),
('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'),
('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'),
('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'),
('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'),
('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'),
('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'),
('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'),
('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'),
('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'),
('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'),
('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'),
('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'),
('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'),
('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'),
('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'),
('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'),
('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'),
('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'),
('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'),
('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'),
('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'),
('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'),
('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'),
('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'),
('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'),
('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'),
('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'),
)
MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled")
class MplexError(Exception):
"""A user-facing failure: the text is shown verbatim as the command's error."""
class MplexKeys:
"""Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what
a terminal sends, and byte encoding for send-keys."""
ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC",
"Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"}
NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace",
"Escape", "Space") + tuple("F%d" % n for n in range(1, 13))
FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End",
"pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab",
"enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"}
CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"}
CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24}
SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"}
CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F}
@classmethod
def canonical(cls, spec):
if spec == " ":
return "Space"
text = str(spec).strip()
if not text:
raise MplexError("empty key name")
mods = set()
while len(text) >= 3 and text[1] == "-" and text[0] in "CMS":
mods.add(text[0])
text = text[2:]
if len(text) == 2 and text[0] == "^":
mods.add("C")
text = text[1]
text = cls.ALIASES.get(text, text)
if len(text) == 1:
if text == " ":
text = "Space"
elif "C" in mods and text.isalpha():
text = text.lower()
elif "S" in mods and text.isalpha():
text = text.upper()
mods.discard("S")
elif text not in cls.NAMED:
raise MplexError("unknown key: %s" % spec)
if "C" in mods:
if text == "i":
mods.discard("C")
text = "Tab"
elif text == "m":
mods.discard("C")
text = "Enter"
elif text == "[":
mods.discard("C")
text = "Escape"
return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text
@classmethod
def name_of_char(cls, raw):
"""Canonical name for one decoded character (no escape prefix)."""
if len(raw) == 1:
byte = raw[0]
if byte == 0x00:
return "C-Space"
if byte == 0x09:
return "Tab"
if byte == 0x0D:
return "Enter"
if byte == 0x1B:
return "Escape"
if byte == 0x20:
return "Space"
if byte == 0x7F:
return "BSpace"
if 0x01 <= byte <= 0x1A:
return "C-" + chr(byte + 0x60)
if 0x1C <= byte <= 0x1F:
return "C-" + "\\]^_"[byte - 0x1C]
return chr(byte)
return raw.decode("utf-8", "replace")
@classmethod
def split(cls, data, final=False):
"""bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys
(they are still forwarded). A lone ESC waits until final=True."""
tokens = []
buf = bytes(data)
while buf:
if buf[0] != 0x1B:
length = 1
lead = buf[0]
if lead >= 0xF0:
length = 4
elif lead >= 0xE0:
length = 3
elif lead >= 0xC0:
length = 2
if length > len(buf):
if not final:
break
length = len(buf)
raw = buf[:length]
tokens.append((cls.name_of_char(raw), raw))
buf = buf[length:]
continue
if len(buf) == 1:
if not final:
break
tokens.append(("Escape", b"\x1b"))
buf = b""
break
event, rest = tui_parse_one(buf, final)
if event == "need_more":
if not final:
break
tokens.append(("Escape", b"\x1b"))
buf = buf[1:]
continue
if event == "escape":
tokens.append(("Escape", b"\x1b"))
buf = buf[1:]
continue
consumed = len(buf) - len(rest)
if consumed <= 0:
consumed = 1
raw = buf[:consumed]
buf = buf[consumed:]
tokens.append((cls._name_of_event(event, raw), raw))
return tokens, buf
@classmethod
def _name_of_event(cls, event, raw):
if not isinstance(event, TuiKey):
return ""
if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"):
inner = cls.name_of_char(raw[1:])
try:
return cls.canonical("M-" + inner)
except MplexError:
return ""
key = event.key
base = key.split("+")[-1] if not key.endswith("+") else "+"
if base in cls.FROM_TUI:
base = cls.FROM_TUI[base]
elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit():
base = "F" + base[1:]
else:
return ""
prefix = ""
mods = set(getattr(event, "modifiers", None) or [])
if "ctrl" in mods:
prefix += "C-"
if "alt" in mods or "meta" in mods:
prefix += "M-"
if "shift" in mods:
prefix += "S-"
try:
return cls.canonical(prefix + base)
except MplexError:
return ""
@classmethod
def to_bytes(cls, name, app_cursor=False):
"""Bytes a terminal would send for a canonical key name."""
mods = ""
text = name
while len(text) >= 3 and text[1] == "-" and text[0] in "CMS":
mods += text[0]
text = text[2:]
if len(text) == 1 or text == "Space":
char = " " if text == "Space" else text
data = char.encode("utf-8")
if "C" in mods:
if char == " ":
data = b"\x00"
elif char.isalpha() and char.isascii():
data = bytes([ord(char.upper()) & 0x1F])
elif char in cls.CTRL_PUNCT:
data = bytes([cls.CTRL_PUNCT[char]])
return (b"\x1b" if "M" in mods else b"") + data
simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"}
if text in simple and not ("S" in mods and text not in ("BTab",)):
return (b"\x1b" if "M" in mods else b"") + simple[text]
mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0)
if text in cls.CSI_FINAL:
if mod_code > 1:
return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii")
return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii")
if text in cls.CSI_TILDE:
if mod_code > 1:
return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii")
return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii")
if text in cls.SS3_FUNC:
if mod_code > 1:
return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii")
return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii")
raise MplexError("unknown key: %s" % name)
@classmethod
def app_cursor_form(cls, raw):
"""Plain CSI cursor keys become SS3 when the pane asked for application cursor mode."""
if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"):
return b"\x1bO" + raw[2:3]
return raw
class MplexArgs:
"""getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value."""
def __init__(self, flags, positional):
self.flags = flags
self.positional = positional
def has(self, flag):
return flag in self.flags
def get(self, flag, default=None):
values = self.flags.get(flag)
if not values:
return default
return values[-1]
def all(self, flag):
return list(self.flags.get(flag) or [])
def int(self, flag, default=None):
value = self.get(flag)
if value is None or value is True:
return default
try:
return int(value)
except ValueError:
raise MplexError("-%s expects a number: %s" % (flag, value))
@classmethod
def parse(cls, name, spec, words, min_args=0, max_args=None):
takes = set()
known = set()
index = 0
while index < len(spec):
known.add(spec[index])
if index + 1 < len(spec) and spec[index + 1] == ":":
takes.add(spec[index])
index += 1
index += 1
flags = {}
positional = []
pos = 0
while pos < len(words):
word = words[pos]
if word == "--":
positional.extend(words[pos + 1:])
break
if len(word) < 2 or word[0] != "-":
positional.extend(words[pos:])
break
inner = 1
while inner < len(word):
letter = word[inner]
if letter not in known:
raise MplexError("command %s: unknown flag -%s" % (name, letter))
if letter in takes:
if inner + 1 < len(word):
value = word[inner + 1:]
else:
pos += 1
if pos >= len(words):
raise MplexError("command %s: -%s expects an argument" % (name, letter))
value = words[pos]
flags.setdefault(letter, []).append(value)
break
flags.setdefault(letter, []).append(True)
inner += 1
pos += 1
if len(positional) < min_args or (max_args is not None and len(positional) > max_args):
raise MplexError("command %s: wrong number of arguments" % name)
return cls(flags, positional)
class MplexCommandParser:
"""Splits tmux-syntax command text into commands of words: quotes, backslash,
{braces} as one argument, ';' and newlines between commands."""
@staticmethod
def parse(text):
commands = []
words = []
cur = []
has = False
i = 0
n = len(text)
def end_word():
nonlocal cur, has
if has:
words.append("".join(cur))
cur = []
has = False
def end_command():
nonlocal words
end_word()
if words:
commands.append(words)
words = []
while i < n:
c = text[i]
if c in " \t\r":
end_word()
i += 1
elif c == "\n" or c == ";":
end_command()
i += 1
elif c == "\\":
if i + 1 < n:
if text[i + 1] == "\n":
i += 2
continue
cur.append(text[i + 1])
has = True
i += 2
elif c == "'":
j = text.find("'", i + 1)
if j < 0:
raise MplexError("unterminated single quote")
cur.append(text[i + 1:j])
has = True
i = j + 1
elif c == '"':
j = i + 1
piece = []
while j < n and text[j] != '"':
if text[j] == "\\" and j + 1 < n:
nxt = text[j + 1]
piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt))
j += 2
else:
piece.append(text[j])
j += 1
if j >= n:
raise MplexError("unterminated double quote")
cur.append("".join(piece))
has = True
i = j + 1
elif c == "{" and not has:
depth = 0
j = i
quote = ""
while j < n:
ch = text[j]
if quote:
if ch == "\\" and quote == '"':
j += 1
elif ch == quote:
quote = ""
elif ch in "'\"":
quote = ch
elif ch == "\\":
j += 1
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
j += 1
if depth != 0 or j >= n:
raise MplexError("unterminated { block")
cur.append(text[i + 1:j].strip())
has = True
i = j + 1
else:
cur.append(c)
has = True
i += 1
end_command()
return commands
@staticmethod
def quote(word):
word = str(word)
if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word):
return word
return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"'
class MplexStyle:
"""tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell
format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes."""
NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")
ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64,
"reverse": 8, "hidden": 128, "strikethrough": 16}
@classmethod
def color(cls, text, background):
text = text.strip().lower()
if text in ("default", "terminal", ""):
return None
base = 40 if background else 30
if text in cls.NAMES:
return str(base + cls.NAMES.index(text))
if text.startswith("bright") and text[6:] in cls.NAMES:
return str(base + 60 + cls.NAMES.index(text[6:]))
if text.startswith("colour") or text.startswith("color"):
digits = text[6:] if text.startswith("colour") else text[5:]
if digits.isdigit() and int(digits) < 256:
return "%d;5;%d" % (48 if background else 38, int(digits))
if text.startswith("#") and len(text) == 7:
try:
red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)
except ValueError:
raise MplexError("bad colour: %s" % text)
return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue)
raise MplexError("bad colour: %s" % text)
@classmethod
def parse(cls, text, base=(None, None, 0), reset=None):
"""Apply a style string on top of base; "default" returns to reset (base when omitted)."""
fg, bg, attr = base
reset = base if reset is None else reset
for item in re.split(r"[,\s]+", str(text).strip()):
if not item:
continue
low = item.lower()
if low == "default":
fg, bg, attr = reset
elif low in ("none", "noattr"):
attr = 0
elif low.startswith("fg="):
fg = cls.color(item[3:], False)
elif low.startswith("bg="):
bg = cls.color(item[3:], True)
elif low in cls.ATTRS:
attr |= cls.ATTRS[low]
elif low.startswith("no") and low[2:] in cls.ATTRS:
attr &= ~cls.ATTRS[low[2:]]
elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")):
continue
else:
raise MplexError("bad style: %s" % item)
return (fg, bg, attr)
@staticmethod
def sgr(fg, bg, attr):
codes = ["0"]
for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")):
if attr & bit:
codes.append(code)
if fg:
codes.append(fg)
if bg:
codes.append(bg)
return "\x1b[" + ";".join(codes) + "m"
class MplexOptions:
"""Typed option store with a parent chain: window, session, then global."""
WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~"
DEFS = {
"prefix": ("key", "C-b"), "prefix2": ("key", "None"),
"base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0),
"escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500),
"history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000),
"mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""),
"status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"),
"status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"),
"status-left": ("str", "[#{session_name}] "),
"status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"),
"status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40),
"status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"),
"message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"),
"mode-style": ("style", "noattr,bg=yellow,fg=black"),
"copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"),
"copy-mode-mark-style": ("style", "bg=red,fg=black"),
"display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000),
"display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"),
"window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"),
"window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"),
"window-status-separator": ("str", " "),
"window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"),
"pane-border-style": ("style", "default"),
"pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"),
"pane-border-lines": ("choice:single|double|heavy|simple", "single"),
"clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"),
"main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24),
"renumber-windows": ("bool", False), "automatic-rename": ("bool", True),
"automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"),
"allow-set-title": ("bool", True),
"default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"),
"detach-on-destroy": ("bool", True), "wrap-search": ("bool", True),
"word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False),
"synchronize-panes": ("bool", False), "copy-command": ("str", ""),
"buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50),
"set-clipboard": ("choice:on|external|off", "off"),
}
def __init__(self, parent=None):
self.parent = parent
self.values = {}
@classmethod
def check(cls, name, raw):
if name not in cls.DEFS:
raise MplexError("unknown or unsupported option: %s" % name)
kind = cls.DEFS[name][0]
text = str(raw)
if kind == "str":
return text
if kind == "bool":
low = text.strip().lower()
if low in ("on", "yes", "true", "1"):
return True
if low in ("off", "no", "false", "0"):
return False
raise MplexError("value is invalid: %s" % text)
if kind == "key":
return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text)
if kind == "colour":
MplexStyle.color(text, False)
return text
if kind == "style":
if "#{" not in text:
MplexStyle.parse(text)
return text
if kind.startswith("int:"):
_tag, low, high = kind.split(":")
try:
value = int(text)
except ValueError:
raise MplexError("value is invalid: %s" % text)
if value < int(low) or value > int(high):
raise MplexError("value out of range %s..%s: %s" % (low, high, text))
return value
if kind.startswith("choice:"):
choices = kind.split(":", 1)[1].split("|")
if text not in choices:
raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text))
return text
raise MplexError("bad option kind for %s" % name)
def get(self, name):
node = self
while node is not None:
if name in node.values:
return node.values[name]
node = node.parent
default = self.DEFS[name][1]
if name in ("mode-keys", "status-keys"):
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
return "vi" if "vi" in editor else "emacs"
return default
def is_set(self, name):
return name in self.values
def set(self, name, raw, append=False):
value = self.check(name, raw)
if append and isinstance(value, str) and name in self.values:
value = self.values[name] + value
self.values[name] = value
return value
def toggle(self, name):
if self.DEFS.get(name, ("",))[0] != "bool":
raise MplexError("only boolean options can be toggled: %s" % name)
self.values[name] = not self.get(name)
def unset(self, name):
if name not in self.DEFS:
raise MplexError("unknown or unsupported option: %s" % name)
self.values.pop(name, None)
@staticmethod
def show(value):
if value is True:
return "on"
if value is False:
return "off"
return str(value)
class MplexBinding:
__slots__ = ("command", "repeat", "note")
def __init__(self, command, repeat=False, note=""):
self.command = command
self.repeat = repeat
self.note = note
class MplexConfig:
"""Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load:
each bad entry is skipped, counted in errors, and shown to the first client."""
ERRORS_MAX = 50
TOP_KEYS = ("options", "keys")
def __init__(self):
self.options = MplexOptions()
self.tables = {}
self.errors = []
self.path = ""
self.skipped = 0
for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS:
self.bind(table, MplexKeys.canonical(key), command, repeat, note)
def error(self, text):
self.skipped += 1
if len(self.errors) < self.ERRORS_MAX:
self.errors.append(text)
def bind(self, table, key, command, repeat=False, note=""):
self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note)
def unbind(self, table, key):
return self.tables.get(table, {}).pop(key, None) is not None
def lookup(self, table, key):
return self.tables.get(table, {}).get(key)
@staticmethod
def default_path(home):
return os.path.join(home, MPLEX_CONFIG_NAME)
@classmethod
def load(cls, path):
config = cls()
config.path = path
config.apply_file(path)
return config
def apply_file(self, path):
try:
if os.path.getsize(path) > MPLEX_CONFIG_MAX:
self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX))
return
with open(path, "rb") as handle:
raw = handle.read()
except FileNotFoundError:
return
except OSError as exc:
self.error("%s: cannot read: %s" % (path, short_error(exc)))
return
try:
import tomllib
except ImportError:
self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path)
return
try:
data = tomllib.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
self.error("%s: not valid TOML: %s" % (path, exc))
return
self.apply_data(data)
def apply_data(self, data):
for key in data:
if key not in self.TOP_KEYS:
self.error("unknown top-level key [%s] (use options, keys)" % key)
options = data.get("options", {})
if not isinstance(options, dict):
self.error("[options] must be a table")
options = {}
for name, value in options.items():
try:
if isinstance(value, bool):
value = "on" if value else "off"
elif not isinstance(value, (str, int)):
raise MplexError("value must be a string, number or boolean")
self.options.set(name, value)
except MplexError as exc:
self.error("options.%s: %s" % (name, exc))
keys = data.get("keys", {})
if not isinstance(keys, dict):
self.error("[keys] must be a table")
return
clear = keys.get("clear", [])
if not isinstance(clear, list):
self.error("keys.clear must be a list of table names")
clear = []
for table in clear:
self.tables[str(table)] = {}
for table, entries in keys.items():
if table == "clear":
continue
if not isinstance(entries, dict):
self.error("keys.%s must be a table of key = command" % table)
continue
for key, spec in entries.items():
self._apply_binding(table, key, spec)
def _apply_binding(self, table, key, spec):
where = "keys.%s.%s" % (table, key)
try:
name = MplexKeys.canonical(key)
if spec is False or spec == "":
self.unbind(table, name)
return
note = ""
repeat = False
if isinstance(spec, dict):
unknown = [item for item in spec if item not in ("command", "repeat", "note")]
if unknown:
raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0])
command = spec.get("command")
repeat = bool(spec.get("repeat", False))
note = str(spec.get("note", ""))
else:
command = spec
if not isinstance(command, str) or not command.strip():
raise MplexError("command must be a non-empty string, or false to unbind")
for words in MplexCommandParser.parse(command):
MplexCommands.resolve(words[0])
self.bind(table, name, command, repeat, note)
except MplexError as exc:
self.error("%s: %s" % (where, exc))
class MplexFormat:
"""tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as
=N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through."""
SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index",
"S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"}
DEPTH_MAX = 12
@staticmethod
def match_close(text, start, opener, closer):
"""Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1."""
depth = 1
pos = start + 1
while pos < len(text):
ch = text[pos]
if ch == "#" and pos + 1 < len(text):
if text[pos + 1] == opener:
depth += 1
pos += 2
continue
if ch == closer:
depth -= 1
if depth == 0:
return pos
pos += 1
return -1
@classmethod
def split_commas(cls, text):
parts = []
cur = []
depth = 0
pos = 0
while pos < len(text):
ch = text[pos]
if ch == "#" and pos + 1 < len(text):
nxt = text[pos + 1]
if nxt == "," and depth == 0:
cur.append("#,")
pos += 2
continue
if nxt in "{(":
depth += 1
cur.append(text[pos:pos + 2])
pos += 2
continue
cur.append(text[pos:pos + 2])
pos += 2
continue
if ch == "}" or ch == ")":
depth = max(0, depth - 1)
if ch == "," and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
pos += 1
parts.append("".join(cur))
return parts
@classmethod
def expand(cls, text, resolve, depth=0):
if depth > cls.DEPTH_MAX or not text:
return ""
out = []
pos = 0
n = len(text)
while pos < n:
ch = text[pos]
if ch != "#" or pos + 1 >= n:
out.append(ch)
pos += 1
continue
nxt = text[pos + 1]
if nxt == "#":
out.append("#")
pos += 2
elif nxt == "{":
end = cls.match_close(text, pos + 1, "{", "}")
if end < 0:
out.append(ch)
pos += 1
else:
out.append(cls.evaluate(text[pos + 2:end], resolve, depth))
pos = end + 1
elif nxt == "(":
end = cls.match_close(text, pos + 1, "(", ")")
if end < 0:
out.append(ch)
pos += 1
else:
out.append(resolve("(" + text[pos + 2:end]) or "")
pos = end + 1
elif nxt == "[":
end = text.find("]", pos + 2)
if end < 0:
out.append(ch)
pos += 1
else:
out.append(text[pos:end + 1])
pos = end + 1
elif nxt in cls.SHORT:
out.append(resolve(cls.SHORT[nxt]) or "")
pos += 2
elif nxt in ",}":
out.append(nxt)
pos += 2
else:
out.append(ch)
pos += 1
return "".join(out)
@staticmethod
def truthy(value):
return value not in ("", "0", None)
@classmethod
def value_of(cls, text, resolve, depth):
"""A variable name or a nested format: its text value."""
if "#" in text:
return cls.expand(text, resolve, depth + 1)
value = resolve(text)
return "" if value is None else str(value)
@classmethod
def evaluate(cls, inner, resolve, depth):
if inner.startswith("?"):
parts = cls.split_commas(inner[1:])
index = 0
while index + 1 < len(parts):
cond = cls.value_of(parts[index], resolve, depth)
if cls.truthy(cond):
return cls.expand(parts[index + 1], resolve, depth + 1)
index += 2
if index < len(parts):
return cls.expand(parts[index], resolve, depth + 1)
return ""
colon = cls.modifier_colon(inner)
if colon < 0:
value = resolve(inner)
return "" if value is None else str(value)
mods = inner[:colon]
rest = inner[colon + 1:]
return cls.apply_modifiers(mods, rest, resolve, depth)
@staticmethod
def modifier_colon(inner):
"""Index of the ':' that ends a modifier list, or -1 when inner is a plain name."""
colon = inner.find(":")
if colon <= 0:
return -1
head = inner[:colon]
if "#" in head or "{" in head or " " in head:
return -1
return colon
@classmethod
def apply_modifiers(cls, mods, rest, resolve, depth):
specs = [item for item in mods.split(";") if item]
text = None
for spec in specs:
if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"):
parts = cls.split_commas(rest)
left = cls.expand(parts[0], resolve, depth + 1) if parts else ""
right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else ""
return cls.compare(spec, left, right)
if spec in ("m", "m/r", "m/ri", "m/i"):
parts = cls.split_commas(rest)
pattern = cls.expand(parts[0], resolve, depth + 1) if parts else ""
subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else ""
flags = re.IGNORECASE if spec.endswith("i") else 0
if spec.startswith("m/r"):
try:
return "1" if re.search(pattern, subject, flags) else "0"
except re.error:
return "0"
import fnmatch
return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0"
if spec == "l":
return rest
if text is None:
text = cls.value_of(rest, resolve, depth)
text = cls.apply_one(spec, text, resolve, depth)
return text or ""
@classmethod
def apply_one(cls, spec, text, resolve, depth):
if spec == "E" or spec == "T":
return cls.expand(text, resolve, depth + 1)
if spec == "q":
return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text)
if spec == "t":
try:
return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text)))
except (ValueError, OverflowError, OSError):
return text
if spec == "n":
return str(len(text))
if spec == "b":
return os.path.basename(text)
if spec == "d":
return os.path.dirname(text)
if spec == "!":
return "0" if cls.truthy(text) else "1"
if spec == "!!":
return "1" if cls.truthy(text) else "0"
if spec.startswith("="):
body = spec[1:]
marker = ""
if body.startswith("/"):
_blank, count, marker = (body.split("/", 2) + ["", ""])[:3]
body = count
try:
limit = int(body)
except ValueError:
return text
if limit >= 0 and len(text) > limit:
return text[:limit] + marker
if limit < 0 and len(text) > -limit:
return marker + text[limit:]
return text
if spec.startswith("s/"):
fields = spec.split("/")
if len(fields) >= 3:
try:
return re.sub(fields[1], fields[2], text)
except re.error:
return text
return text
@staticmethod
def compare(op, left, right):
if op.startswith("e|"):
kind = op.split("|")[1] if "|" in op else ""
try:
a, b = float(left), float(right)
except ValueError:
return "0"
table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0,
"==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}
if kind not in table:
return "0"
value = table[kind]
if isinstance(value, bool):
return "1" if value else "0"
return str(int(value)) if float(value).is_integer() else str(value)
if op == "==":
return "1" if left == right else "0"
if op == "!=":
return "1" if left != right else "0"
if op == "&&":
return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0"
if op == "||":
return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0"
try:
a, b = float(left), float(right)
except ValueError:
a, b = left, right
return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0"
@staticmethod
def with_time(text, stamp=None):
if "%" not in text:
return text
try:
return time.strftime(text, time.localtime(stamp))
except (ValueError, OverflowError):
return text
@staticmethod
def strip_styles(text):
return re.sub(r"#\[[^\]]*\]", "", text)
class MplexLayoutNode:
__slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse")
def __init__(self, kind, pane=None, w=1, h=1):
self.reverse = False
self.kind = kind
self.children = []
self.parent = None
self.pane = pane
self.x = 0
self.y = 0
self.w = w
self.h = h
def leaves(self):
if self.kind == "pane":
return [self]
found = []
for child in self.children:
found.extend(child.leaves())
return found
def adopt(self, child):
child.parent = self
self.children.append(child)
return child
class MplexLayout:
"""Split tree: a node is a pane or a container laid out side by side ("lr") or
stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are
rescaled proportionally when the window changes size."""
@staticmethod
def size_of(node, kind):
return node.w if kind == "lr" else node.h
@staticmethod
def set_size(node, kind, value):
if kind == "lr":
node.w = value
else:
node.h = value
@classmethod
def assign(cls, node, x, y, w, h):
node.x, node.y, node.w, node.h = x, y, w, h
if node.kind == "pane":
return
kids = node.children
count = len(kids)
kind = node.kind
total = (w if kind == "lr" else h) - (count - 1)
if total < count:
sizes = [1] * count
else:
sizes = [max(1, cls.size_of(kid, kind)) for kid in kids]
weight = sum(sizes) or count
if weight != total:
sizes = [max(1, int(round(size * total / weight))) for size in sizes]
diff = total - sum(sizes)
while diff != 0:
moved = False
for index in (range(count) if diff > 0 else range(count - 1, -1, -1)):
if diff > 0:
sizes[index] += 1
diff -= 1
moved = True
elif sizes[index] > 1:
sizes[index] -= 1
diff += 1
moved = True
if diff == 0:
break
if not moved:
break
order = list(zip(kids, sizes))
if node.reverse:
order.reverse()
pos = x if kind == "lr" else y
for kid, size in order:
if kind == "lr":
cls.assign(kid, pos, y, size, h)
else:
cls.assign(kid, x, pos, w, size)
pos += size + 1
@classmethod
def split(cls, root, leaf, kind, before, new_pane, size=None):
"""Insert new_pane beside leaf. Returns (root, new_leaf)."""
total = cls.size_of(leaf, kind)
if total < 3:
raise MplexError("no space for new pane")
avail = total - 1
if size is None:
new_size = avail - avail // 2 if before else avail // 2
else:
new_size = max(1, min(avail - 1, size))
old_size = avail - new_size
fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h)
parent = leaf.parent
if parent is not None and parent.kind == kind:
index = parent.children.index(leaf)
parent.children.insert(index if before else index + 1, fresh)
fresh.parent = parent
else:
box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h)
box.x, box.y = leaf.x, leaf.y
box.parent = parent
if parent is None:
root = box
else:
parent.children[parent.children.index(leaf)] = box
for item in ((fresh, leaf) if before else (leaf, fresh)):
box.adopt(item)
cls.set_size(leaf, kind, old_size)
cls.set_size(fresh, kind, new_size)
return root, fresh
@classmethod
def remove(cls, root, leaf):
"""Drop leaf, hand its space to a neighbour. Returns the new root (None when empty)."""
parent = leaf.parent
if parent is None:
return None
index = parent.children.index(leaf)
parent.children.remove(leaf)
neighbour = parent.children[index - 1] if index > 0 else parent.children[0]
kind = parent.kind
cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1)
if len(parent.children) == 1:
only = parent.children[0]
grand = parent.parent
only.w, only.h = parent.w, parent.h
only.parent = grand
if grand is None:
return only
slot = grand.children.index(parent)
if only.kind == grand.kind:
grand.children[slot:slot + 1] = only.children
for child in only.children:
child.parent = grand
else:
grand.children[slot] = only
return root
@classmethod
def resize_by(cls, leaf, kind, delta, prefer_after):
"""Move the border of leaf's nearest enclosing "kind" container by delta cells."""
node = leaf
while node.parent is not None and node.parent.kind != kind:
node = node.parent
parent = node.parent
if parent is None:
raise MplexError("no border to resize")
kids = parent.children
index = kids.index(node)
use_after = (index < len(kids) - 1) if prefer_after else (index == 0)
first = index if use_after else index - 1
a, b = kids[first], kids[first + 1]
size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind)
new_a = max(1, min(size_a + size_b - 1, size_a + delta))
cls.set_size(a, kind, new_a)
cls.set_size(b, kind, size_a + size_b - new_a)
@staticmethod
def shares(total, count, spare_first):
"""Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last."""
room = total - (count - 1)
base, extra = divmod(room, count)
sizes = [base] * count
for index in (range(extra) if spare_first else range(count - extra, count)):
sizes[index] += 1
return sizes
@classmethod
def resize_to(cls, leaf, kind, size):
"""Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before)."""
change = size - cls.size_of(leaf, kind)
node = leaf
while node.parent is not None and node.parent.kind != kind:
node = node.parent
if node.parent is None:
raise MplexError("no border to resize")
has_next = node.parent.children.index(node) < len(node.parent.children) - 1
cls.resize_by(leaf, kind, change if has_next else -change, True)
@classmethod
def preset(cls, name, panes, width, height, main_width, main_height):
count = len(panes)
leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes]
mirrored = name.endswith("-mirrored")
base = name[:-len("-mirrored")] if mirrored else name
if count == 1:
leaves[0].w, leaves[0].h = width, height
return leaves[0]
def box(kind, items, w, h, spare_first=True):
node = MplexLayoutNode(kind, w=w, h=h)
for item in items:
node.adopt(item)
for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)):
if kind == "lr":
item.w, item.h = size, h
else:
item.w, item.h = w, size
return node
if base == "even-horizontal":
return box("lr", leaves, width, height)
if base == "even-vertical":
return box("tb", leaves, width, height)
if base in ("main-horizontal", "main-vertical"):
horizontal = base == "main-horizontal"
span = height if horizontal else width
main_size = max(1, min(span - 2, main_height if horizontal else main_width))
main = leaves[0]
rest = leaves[1:]
other_size = span - main_size - 1
if horizontal:
others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size)
main.w, main.h, others.w, others.h = width, main_size, width, other_size
root = box("tb", [main, others], width, height)
else:
others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height)
main.w, main.h, others.w, others.h = main_size, height, other_size, height
root = box("lr", [main, others], width, height)
if horizontal:
main.h, others.h = main_size, other_size
else:
main.w, others.w = main_size, other_size
root.reverse = mirrored
return root
rows = columns = 1
while rows * columns < count:
rows += 1
if rows * columns < count:
columns += 1
lines = []
for start in range(0, count, columns):
chunk = leaves[start:start + columns]
lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False))
if len(lines) == 1:
return lines[0]
root = box("tb", lines, width, height, spare_first=False)
for line in lines:
line.w = width
return root
@staticmethod
def neighbour(panes, current, direction, width, height):
left, top = current.x, current.y
right, bottom = current.x + current.w, current.y + current.h
def overlaps(other):
if direction in ("L", "R"):
return other.y < bottom and other.y + other.h > top
return other.x < right and other.x + other.w > left
wanted = []
for other in panes:
if other is current or not overlaps(other):
continue
if direction == "L" and other.x + other.w + 1 == left:
wanted.append(other)
elif direction == "R" and other.x == right + 1:
wanted.append(other)
elif direction == "U" and other.y + other.h + 1 == top:
wanted.append(other)
elif direction == "D" and other.y == bottom + 1:
wanted.append(other)
if not wanted:
for other in panes:
if other is current or not overlaps(other):
continue
if direction == "L" and other.x + other.w >= width:
wanted.append(other)
elif direction == "R" and other.x == 0:
wanted.append(other)
elif direction == "U" and other.y + other.h >= height:
wanted.append(other)
elif direction == "D" and other.y == 0:
wanted.append(other)
if not wanted:
return None
return max(wanted, key=lambda item: item.last_active)
class MplexPane:
"""One pty-backed shell: its screen, scrollback, output queue and mode stack."""
def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"):
self.id = ident
self.window = None
self.x = 0
self.y = 0
self.w = max(1, cols)
self.h = max(1, rows)
self.screen = TuiVtScreen(self.h, self.w)
self.screen.history = collections.deque(maxlen=max(0, history_limit))
self.master_fd = None
self.pid = None
self.dead = False
self.exit_status = None
self.pending_in = bytearray()
self.dropped = 0
self.modes = []
self.prefix_count = 1
self.created = time.time()
self.last_active = 0.0
self.shell_name = ""
self.last_search = None
self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident))
self._log = None
self._log_size = 0
self.input_bytes = 0
@property
def tag(self):
return "%%%d" % self.id
@property
def title(self):
return self.screen.title or socket.gethostname()
def spawn(self, file, argv, cwd, env):
master, slave = os.openpty()
try:
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0))
attrs = termios.tcgetattr(slave)
attrs[0] |= getattr(termios, "IUTF8", 0)
termios.tcsetattr(slave, termios.TCSANOW, attrs)
except (OSError, termios.error):
pass
log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
self._log = os.fdopen(log_fd, "ab", buffering=0)
pid = os.fork()
if pid == 0:
try:
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
for fd in (0, 1, 2):
os.dup2(slave, fd)
if slave > 2:
os.close(slave)
os.close(master)
for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM):
signal.signal(sig, signal.SIG_DFL)
try:
os.chdir(cwd)
except OSError:
pass
os.execvpe(file, argv, env)
except BaseException:
pass
finally:
os._exit(127)
os.close(slave)
os.set_blocking(master, False)
self.master_fd = master
self.pid = pid
self.shell_name = os.path.basename(file)
def place(self, x, y, w, h):
self.x, self.y = x, y
w, h = max(1, w), max(1, h)
if (w, h) == (self.w, self.h):
return
self.w, self.h = w, h
self.screen.resize(h, w)
self.apply_winsize()
for mode in self.modes:
mode.on_resize()
def apply_winsize(self):
if self.master_fd is None:
return
try:
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0))
except OSError:
pass
def feed(self, data):
history = self.screen.history
before = self.screen.history_pushed
before_len = len(history) if history is not None else 0
self.screen.feed(data)
pushed = self.screen.history_pushed - before
evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0
if self._log is not None:
self.log(data)
replies = self.screen.take_replies()
if replies:
self.write(replies)
for mode in self.modes:
mode.on_output(pushed, evicted)
def log(self, data):
"""Append to the session log; at MPLEX_LOG_MAX it rolls over to <name>.1 (one generation kept)."""
try:
if self._log_size + len(data) > MPLEX_LOG_MAX:
self._log.close()
os.replace(self.log_path, self.log_path + ".1")
fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
self._log = os.fdopen(fd, "ab", buffering=0)
self._log_size = 0
self._log.write(data)
self._log_size += len(data)
except OSError:
pass
def write(self, data):
if not data:
return
if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX:
self.dropped += len(data)
return
self.pending_in += data
def drain(self):
if not self.pending_in or self.master_fd is None:
return
try:
written = os.write(self.master_fd, bytes(self.pending_in[:4096]))
del self.pending_in[:written]
except BlockingIOError:
pass
except OSError:
self.pending_in.clear()
def top_mode(self):
return self.modes[-1] if self.modes else None
def push_mode(self, mode):
self.modes.append(mode)
return mode
def pop_mode(self, mode=None):
if mode is None:
if self.modes:
self.modes.pop()
elif mode in self.modes:
self.modes.remove(mode)
def find_mode(self, cls):
for mode in self.modes:
if isinstance(mode, cls):
return mode
return None
def foreground_command(self):
try:
group = os.tcgetpgrp(self.master_fd)
with open("/proc/%d/comm" % group, "r") as handle:
return handle.read().strip() or self.shell_name
except (OSError, TypeError, ValueError):
return self.shell_name
def current_path(self):
try:
return os.readlink("/proc/%d/cwd" % self.pid)
except (OSError, TypeError):
return ""
def history_size(self):
history = self.screen.history
return len(history) if history is not None else 0
def clear_history(self):
if self.screen.history is not None:
self.screen.history.clear()
def close(self, hard=False):
"""Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead."""
if self.pid is not None:
sig = signal.SIGKILL if hard else signal.SIGHUP
try:
os.killpg(self.pid, sig)
except OSError:
try:
os.kill(self.pid, sig)
except OSError:
pass
if self.master_fd is not None:
try:
os.close(self.master_fd)
except OSError:
pass
self.master_fd = None
if self._log is not None:
try:
self._log.close()
except OSError:
pass
self._log = None
def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24):
"""Last lines of the raw session log, reading only the tail in growing chunks."""
try:
size = os.path.getsize(self.log_path)
except OSError:
return ""
with open(self.log_path, "rb") as handle:
read_from = size
data = b""
while True:
read_from = max(0, read_from - chunk_size)
handle.seek(read_from)
data = handle.read(size - read_from)
if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes:
break
return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:])
class MplexWindow:
def __init__(self, ident, name, width, height, parent_options):
self.id = ident
self.name = name
self.root = None
self.active = None
self.last_pane = None
self.zoomed = None
self.width = width
self.height = height
self.layout_index = -1
self.options = MplexOptions(parent_options)
self.auto_rename = True
self.created = time.time()
self.session = None
@property
def tag(self):
return "@%d" % self.id
def panes(self):
return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else []
def relayout(self):
if self.root is None:
return
MplexLayout.assign(self.root, 0, 0, self.width, self.height)
for leaf in self.root.leaves():
leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h)
if self.zoomed is not None and self.zoomed in self.panes():
self.zoomed.place(0, 0, self.width, self.height)
else:
self.zoomed = None
def visible_panes(self):
return [self.zoomed] if self.zoomed is not None else self.panes()
def leaf_of(self, pane):
for leaf in self.root.leaves():
if leaf.pane is pane:
return leaf
return None
def set_active(self, pane):
if pane is not self.active:
self.last_pane = self.active
self.active = pane
pane.last_active = time.monotonic()
class MplexSession:
def __init__(self, ident, name, parent_options, cwd, env):
self.id = ident
self.name = name
self.windows = {}
self.current = None
self.history = []
self.created = time.time()
self.options = MplexOptions(parent_options)
self.cwd = cwd
self.env = env
self.last_attached = time.time()
@property
def tag(self):
return "$%d" % self.id
def window(self):
return self.windows.get(self.current)
def indexes(self):
return sorted(self.windows)
def free_index(self, base):
index = base
while index in self.windows:
index += 1
return index
@property
def last(self):
"""Index of the window that was current before this one, or None."""
for window in self.history:
index = self.index_of(window)
if index is not None:
return index
return None
def select(self, index):
if index not in self.windows:
return False
if index != self.current:
previous = self.window()
target = self.windows[index]
self.history = [item for item in self.history if item is not previous and item is not target]
if previous is not None:
self.history.insert(0, previous)
self.current = index
return True
def index_of(self, window):
for index, item in self.windows.items():
if item is window:
return index
return None
class MplexCanvas:
"""A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that
turns two successive grids into the escape sequences that repaint only what changed."""
BLANK = (" ", None, None, 0)
GLYPHS = {
"single": "─│┌┐└┘├┤┬┴┼",
"double": "═║╔╗╚╝╠╣╦╩╬",
"heavy": "━┃┏┓┗┛┣┫┳┻╋",
"simple": "-|+++++++++",
}
def __init__(self, rows, cols):
self.rows = rows
self.cols = cols
self.cells = [[self.BLANK] * cols for _row in range(rows)]
self.cursor = None
def put(self, x, y, text, style=(None, None, 0), limit=None):
if y < 0 or y >= self.rows:
return x
fg, bg, attr = style
row = self.cells[y]
for char in text:
if limit is not None and limit <= 0:
break
if 0 <= x < self.cols:
row[x] = (char, fg, bg, attr)
x += 1
if limit is not None:
limit -= 1
return x
def put_cells(self, x, y, cells):
if y < 0 or y >= self.rows:
return
row = self.cells[y]
for cell in cells:
if 0 <= x < self.cols:
row[x] = cell
x += 1
def blit(self, x, y, block, width):
for index, source in enumerate(block):
target = y + index
if target < 0 or target >= self.rows:
continue
row = self.cells[target]
take = min(width, len(source), self.cols - x)
if take > 0:
row[x:x + take] = source[:take]
def fill(self, x, y, w, h, style=(None, None, 0), char=" "):
cell = (char, style[0], style[1], style[2])
for target in range(max(0, y), min(self.rows, y + h)):
row = self.cells[target]
for col in range(max(0, x), min(self.cols, x + w)):
row[col] = cell
@classmethod
def border_char(cls, up, down, left, right, lines):
glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"])
horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs
if (up or down) and not (left or right):
return vert
if (left or right) and not (up or down):
return horiz
if up and down and left and right:
return cross
if up and down and right:
return tee_r
if up and down and left:
return tee_l
if left and right and down:
return tee_d
if left and right and up:
return tee_u
if down and right:
return tl
if down and left:
return tr
if up and right:
return bl
if up and left:
return br
return cross
def encode(self, previous):
"""Escape bytes repainting this grid over previous (a list of row tuples, or None)."""
snapshot = [tuple(row) for row in self.cells]
out = ["\x1b[?25l"]
if previous is None or len(previous) != len(snapshot):
out.append("\x1b[0m\x1b[2J")
previous = None
for index, row in enumerate(snapshot):
if previous is not None and previous[index] == row:
continue
out.append("\x1b[%d;1H" % (index + 1))
current = None
for char, fg, bg, attr in row:
style = (fg, bg, attr)
if style != current:
out.append(MplexStyle.sgr(fg, bg, attr))
current = style
out.append(char)
out.append("\x1b[0m")
if self.cursor is not None:
out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1))
return "".join(out).encode("utf-8", "replace"), snapshot
class MplexStatusText:
"""Format-expanded text with #[...] style markers turned into styled cells."""
@staticmethod
def cells(text, base, reset=None):
reset = base if reset is None else reset
current = base
out = []
pos = 0
while pos < len(text):
if text.startswith("#[", pos):
end = text.find("]", pos + 2)
if end > 0:
try:
current = MplexStyle.parse(text[pos + 2:end], current, reset)
except MplexError:
pass
pos = end + 1
continue
out.append((text[pos], current[0], current[1], current[2]))
pos += 1
return out
class MplexMode:
"""Pane mode base: owns the pane's keyboard and its drawing while it is on top."""
name = "mode"
def __init__(self, server, pane):
self.server = server
self.pane = pane
def render(self, client):
raise NotImplementedError
def key(self, name, raw, client):
return None
def on_output(self, pushed, evicted):
return None
def on_resize(self):
return None
def close(self):
self.pane.pop_mode(self)
self.server.mark_dirty(self.pane.window)
class MplexCopyMode(MplexMode):
"""Scrollback browser: movement, search, selection and copy, driven by the
copy-mode / copy-mode-vi key tables through "send-keys -X <command>"."""
name = "copy-mode"
def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True):
super().__init__(server, pane)
self.static_rows = None
if lines is not None:
self.name = "view-mode"
self.static_rows = [[(char, None, None, 0) for char in line] for line in lines]
while len(self.static_rows) < pane.h:
self.static_rows.append([])
self.oy = 0
self.cx = 0 if lines is not None else pane.screen.cx
self.cy = 0 if lines is not None else pane.screen.cy
self.anchor = None
self.rect = False
self.line_select = False
self.mark = None
self.jump = None
self.inc_origin = None
self.show_position = show_position
self.exit_at_bottom = exit_at_bottom
self.count = 1
self.lastcx = 0
self.lastsx = 0
# a search string outlives copy mode; re-entering it starts searching upward, as tmux does
self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None
self._line_cache = (-1, "")
if lines is not None:
self.oy = self.hist()
@property
def rows(self):
return self.pane.h
@property
def cols(self):
return self.pane.w
def hist(self):
if self.static_rows is not None:
return max(0, len(self.static_rows) - self.rows)
return self.pane.history_size()
def total(self):
return self.hist() + self.rows
def top(self):
return self.hist() - self.oy
def abs_y(self):
return self.top() + self.cy
def row(self, ay):
if self.static_rows is not None:
return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else []
hist = self.hist()
if ay < hist:
return self.pane.screen.history[ay]
grid = self.pane.screen.grid
index = ay - hist
return grid[index] if 0 <= index < len(grid) else []
def text(self, ay):
return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip()
def clamp(self):
self.oy = max(0, min(self.hist(), self.oy))
self.cy = max(0, min(self.rows - 1, self.cy))
self.cx = max(0, min(self.cols, self.cx))
def goto(self, x, ay):
ay = max(0, min(self.total() - 1, ay))
top = self.top()
if ay < top:
top = ay
elif ay >= top + self.rows:
top = ay - self.rows + 1
self.oy = max(0, min(self.hist(), self.hist() - top))
self.cy = ay - self.top()
self.cx = max(0, min(self.cols, x))
def on_output(self, pushed, evicted):
if self.static_rows is not None:
return
if self.oy > 0 and pushed:
self.oy += pushed
if evicted:
if self.anchor:
self.anchor = (self.anchor[0], self.anchor[1] - evicted)
if self.mark:
self.mark = (self.mark[0], self.mark[1] - evicted)
self.clamp()
def on_resize(self):
self.clamp()
# ---- rendering -------------------------------------------------------
def vi_keys(self):
return self.server.pane_options(self.pane).get("mode-keys") == "vi"
def selected(self, x, ay):
if self.anchor is None:
return False
ax, ay0 = self.anchor
cx, cy = self.cx, self.abs_y()
extra = 1 if self.vi_keys() else 0
if self.rect:
return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra
if self.line_select:
return min(ay0, cy) <= ay <= max(ay0, cy)
first, last = sorted(((ay0, ax), (cy, cx)))
return first <= (ay, x) < (last[0], last[1] + extra)
def pattern(self):
if not self.search:
return None
return self.compile(self.search[0], len(self.search) > 2 and self.search[2])
def render(self, client):
opts = self.server.pane_options(self.pane)
mode_style = MplexStyle.parse(opts.get("mode-style"))
match_style = MplexStyle.parse(opts.get("copy-mode-match-style"))
pattern = self.pattern()
top = self.top()
out = []
for view_row in range(self.rows):
ay = top + view_row
source = self.row(ay)
cells = []
for col in range(self.cols):
cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK
cells.append(cell)
if pattern is not None:
line = "".join(cell[0] for cell in cells)
for match in pattern.finditer(line):
if match.end() == match.start():
continue
for col in range(match.start(), match.end()):
cells[col] = self.tint(cells[col], match_style)
if self.anchor is not None:
for col in range(self.cols):
if self.selected(col, ay):
cells[col] = self.tint(cells[col], mode_style)
out.append(cells)
if self.show_position and out:
label = "[%d/%d]" % (self.oy, self.hist())
start = max(0, self.cols - len(label))
for index, char in enumerate(label[:self.cols]):
out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2])
return out, (self.cx, self.cy)
@staticmethod
def tint(cell, style):
char, fg, bg, attr = cell
return (char, style[0] or fg, style[1] or bg, attr | style[2])
# ---- keys ------------------------------------------------------------
def key(self, name, raw, client):
table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode"
binding = self.server.config.lookup(table, name)
if binding is not None:
self.server.run_binding(client, binding, self.pane)
return True
def command(self, name, words, client):
"""Run one copy-mode command (send-keys -X name args) count times."""
method = getattr(self, "c_" + name.replace("-", "_"), None)
if method is None:
raise MplexError("unknown copy mode command: %s" % name)
count = max(1, min(self.pane.prefix_count, 10000))
self.pane.prefix_count = 1
self.count = count
self._line_cache = (-1, "")
result = method(words, client)
self.clamp()
self.server.mark_dirty(self.pane.window)
if result == "cancel":
self.close()
# ---- cursor and scrolling --------------------------------------------
# The cursor sits between characters like tmux's: it may stand one past the
# last character of a line (x == line length), and vertical moves remember the
# column they started from (lastcx / lastsx), snapping to the line end the way
# tmux does. These rules were checked against tmux 3.6 with a differential fuzz.
def llen(self, ay):
return len(self.text(ay))
def snap(self):
px = self.llen(self.abs_y())
if (self.cx >= self.lastsx and self.cx != px) or self.cx > px:
self.cx = px
def c_cancel(self, words, client):
return "cancel"
def step_up(self, scroll_only):
ox = self.llen(self.abs_y())
if self.cx != ox:
self.lastcx, self.lastsx = self.cx, ox
self.cx = self.lastcx
if scroll_only or self.cy == 0:
if self.oy < self.hist():
self.oy += 1
else:
self.cy -= 1
self.snap()
def step_down(self, scroll_only):
ox = self.llen(self.abs_y())
if self.cx != ox:
self.lastcx, self.lastsx = self.cx, ox
self.cx = self.lastcx
if scroll_only or self.cy == self.rows - 1:
if self.oy > 0:
self.oy -= 1
else:
self.cy += 1
self.snap()
def c_cursor_up(self, words, client):
for _i in range(self.count):
self.step_up(False)
def c_cursor_down(self, words, client):
for _i in range(self.count):
self.step_down(False)
return self.bottom_exit()
def c_cursor_down_and_cancel(self, words, client):
for _i in range(self.count):
self.step_down(False)
if self.oy == 0 and self.cy == self.rows - 1:
return "cancel"
def bottom_exit(self):
return "cancel" if self.exit_at_bottom and self.oy == 0 else None
def c_cursor_left(self, words, client):
for _i in range(self.count):
ay = self.abs_y()
if self.cx == 0 and ay > 0:
self.goto(self.llen(ay - 1), ay - 1)
elif self.cx > 0:
self.cx -= 1
def c_cursor_right(self, words, client):
for _i in range(self.count):
ay = self.abs_y()
if self.cx >= self.llen(ay):
if ay < self.total() - 1:
self.goto(0, ay + 1)
else:
self.cx += 1
def c_start_of_line(self, words, client):
self.cx = 0
def c_end_of_line(self, words, client):
self.cx = self.llen(self.abs_y())
def c_back_to_indentation(self, words, client):
line = self.text(self.abs_y())
if line:
self.cx = len(line) - len(line.lstrip(" "))
def c_top_line(self, words, client):
self.cx, self.cy = 0, 0
def c_middle_line(self, words, client):
self.cx, self.cy = 0, (self.rows - 1) // 2
def c_bottom_line(self, words, client):
self.cx, self.cy = 0, self.rows - 1
def c_history_top(self, words, client):
self.oy, self.cx, self.cy = self.hist(), 0, 0
def c_history_bottom(self, words, client):
self.oy, self.cy = 0, self.rows - 1
self.cx = self.llen(self.abs_y())
return self.bottom_exit()
def page_size(self, half):
return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1
def remember_column(self):
ox = self.llen(self.abs_y())
if self.cx != ox:
self.lastcx, self.lastsx = self.cx, ox
self.cx = self.lastcx
def page_up(self, half):
size = self.page_size(half)
self.remember_column()
if self.oy + size > self.hist():
self.oy = self.hist()
self.cy = self.cy - size if self.cy > size else 0
else:
self.oy += size
self.snap()
def page_down(self, half):
size = self.page_size(half)
self.remember_column()
if self.oy < size:
self.cy = min(self.rows - 1, self.cy + size)
self.oy = 0
else:
self.oy -= size
self.snap()
def c_page_up(self, words, client):
for _i in range(self.count):
self.page_up(False)
def c_page_down(self, words, client):
for _i in range(self.count):
self.page_down(False)
return self.bottom_exit()
def c_page_down_and_cancel(self, words, client):
for _i in range(self.count):
self.page_down(False)
if self.oy == 0:
return "cancel"
def c_halfpage_up(self, words, client):
for _i in range(self.count):
self.page_up(True)
def c_halfpage_down(self, words, client):
for _i in range(self.count):
self.page_down(True)
return self.bottom_exit()
def c_halfpage_down_and_cancel(self, words, client):
for _i in range(self.count):
self.page_down(True)
if self.oy == 0:
return "cancel"
def c_scroll_up(self, words, client):
for _i in range(self.count):
self.step_up(True)
def c_scroll_down(self, words, client):
for _i in range(self.count):
self.step_down(True)
return self.bottom_exit()
def c_scroll_down_and_cancel(self, words, client):
for _i in range(self.count):
self.step_down(True)
if self.oy == 0:
return "cancel"
def c_scroll_middle(self, words, client):
mid = (self.rows - 1) // 2
delta = self.cy - mid
if delta > 0 and self.oy >= delta:
self.oy -= delta
self.cy = mid
elif delta < 0 and self.hist() - self.oy >= -delta:
self.oy += -delta
self.cy = mid
def c_cursor_centre_vertical(self, words, client):
self.cy = self.rows // 2
def c_cursor_centre_horizontal(self, words, client):
return None
def c_scroll_top(self, words, client):
if self.oy >= self.cy:
self.oy -= self.cy
self.cy = 0
def c_scroll_bottom(self, words, client):
need = self.rows - 1 - self.cy
if self.hist() - self.oy >= need:
self.oy += need
self.cy = self.rows - 1
def c_goto_line(self, words, client):
try:
line = int(words[0])
except (IndexError, ValueError):
raise MplexError("goto-line needs a line number")
self.oy = max(0, min(self.hist(), line))
def c_refresh_from_pane(self, words, client):
return None
def c_toggle_position(self, words, client):
self.show_position = not self.show_position
# ---- words, paragraphs, brackets -------------------------------------
def in_ws(self, x, ay):
return self.char_at(x, ay) == " "
def in_sep(self, x, ay, separators):
return self.char_at(x, ay) in separators
def separators(self, big):
return "" if big else self.server.pane_options(self.pane).get("word-separators")
def char_at(self, x, ay):
if self._line_cache[0] != ay:
self._line_cache = (ay, self.text(ay))
line = self._line_cache[1]
return line[x] if x < len(line) else " "
def advance_blanks(self, x, ay, xx):
"""Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom)."""
last = self.total() - 1
while x > xx or self.in_ws(x, ay):
if x > xx:
if ay == last:
return x, ay, xx, True
ay += 1
x = 0
xx = self.llen(ay)
else:
x += 1
return x, ay, xx, False
def run_end(self, x, ay, xx, separators):
"""x just past the run of separators (or of word characters) that starts at x."""
if self.in_sep(x, ay, separators) and not self.in_ws(x, ay):
x += 1
while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay):
x += 1
else:
x += 1
while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)):
x += 1
return x
def c_next_word(self, words, client, big=False):
separators = self.separators(big)
x, ay = self.cx, self.abs_y()
for _i in range(self.count):
xx = self.llen(ay)
if x > xx:
# past the line end (reachable after centre-vertical or goto-line): tmux carries on skipping
# the first word of the next line before it looks for a word start
if ay == self.total() - 1:
break
ay += 1
x = 0
xx = self.llen(ay)
if not self.in_ws(x, ay):
x = self.run_end(x, ay, xx, separators)
x, ay, xx, _bottom = self.advance_blanks(x, ay, xx)
self.goto(x, ay)
def c_next_space(self, words, client):
self.c_next_word(words, client, True)
def c_next_word_end(self, words, client, big=False):
separators = self.separators(big)
vi = self.server.pane_options(self.pane).get("mode-keys") == "vi"
x, ay = self.cx, self.abs_y()
for _i in range(self.count):
xx = self.llen(ay)
if vi:
if x < xx:
x += 1
elif ay < self.total() - 1:
self.goto(0, ay + 1)
x, ay = 0, ay + 1
xx = self.llen(ay)
x, ay, xx, bottom = self.advance_blanks(x, ay, xx)
if not bottom:
x = self.run_end(x, ay, xx, separators)
if vi and x > 0:
x -= 1
if bottom:
break
self.goto(x, ay)
def c_next_space_end(self, words, client):
self.c_next_word_end(words, client, True)
def c_previous_word(self, words, client, big=False):
separators = self.separators(big)
x, ay = self.cx, self.abs_y()
for _i in range(self.count):
# one step back, then over whitespace (and empty lines) to a word character
while True:
if x == 0:
if ay == 0:
break
ay -= 1
x = self.llen(ay)
else:
x -= 1
if not self.in_ws(x, ay):
break
if self.in_ws(x, ay):
break
letters = not self.in_sep(x, ay, separators)
while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)):
x -= 1
self.goto(x, ay)
def c_previous_space(self, words, client):
self.c_previous_word(words, client, True)
def c_next_paragraph(self, words, client):
ay = self.abs_y()
last = self.total() - 1
for _i in range(self.count):
while ay < last and not self.llen(ay):
ay += 1
while ay < last and self.llen(ay):
ay += 1
self.scroll_to(self.llen(ay), ay)
def c_previous_paragraph(self, words, client):
ay = self.abs_y()
for _i in range(self.count):
while ay > 0 and not self.llen(ay):
ay -= 1
while ay > 0 and self.llen(ay):
ay -= 1
self.scroll_to(0, ay)
def match_bracket(self, x, ay, forward):
"""Position of the bracket matching the one at (x, ay), or None."""
pairs = {"(": ")", "[": "]", "{": "}"}
reverse = {value: key for key, value in pairs.items()}
opener = self.char_at(x, ay)
closer = pairs.get(opener) if forward else reverse.get(opener)
if closer is None:
return None
depth = 0
pos = (x, ay)
for _i in range(20000):
char = self.char_at(*pos)
if char == opener:
depth += 1
elif char == closer:
depth -= 1
if depth == 0:
return pos
pos = self.step(pos[0], pos[1], forward)
if pos is None:
return None
return None
def step(self, x, ay, forward):
"""Next position, line ends counting as one blank cell. None at the buffer edge."""
if forward:
if x < self.llen(ay):
return x + 1, ay
return (0, ay + 1) if ay + 1 < self.total() else None
if x > 0:
return x - 1, ay
return (self.llen(ay - 1), ay - 1) if ay > 0 else None
def c_next_matching_bracket(self, words, client):
ay = self.abs_y()
if not self.llen(ay):
return
here = self.char_at(self.cx, ay)
found = None
if self.vi_keys():
if here in "([{":
found = self.match_bracket(self.cx, ay, True)
elif here in ")]}":
found = self.match_bracket(self.cx, ay, False)
else:
for x in (self.cx, self.cx + 1):
if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "):
found = self.match_bracket(x, ay, True)
if found is not None:
self.goto(*found)
else:
self.c_next_word_end([], None, True)
def c_previous_matching_bracket(self, words, client):
ay = self.abs_y()
if not self.llen(ay):
return
here = self.char_at(self.cx, ay)
found = None
if here in ")]}":
found = self.match_bracket(self.cx, ay, False)
elif self.vi_keys():
if here in "([{" or self.char_at(self.cx + 1, ay) in "([{":
return
elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}":
found = self.match_bracket(self.cx - 1, ay, False)
if found is not None:
self.goto(*found)
else:
self.c_previous_word([], None, True)
# ---- jump ------------------------------------------------------------
def do_jump(self, kind, char):
line = self.text(self.abs_y())
x = self.cx
for _i in range(self.count):
if kind == "f":
found = line.find(char, x + 1)
elif kind == "t":
found = line.find(char, x + 2)
found = found - 1 if found >= 0 else -1
elif kind == "F":
found = line.rfind(char, 0, x)
else:
found = line.rfind(char, 0, max(0, x - 1))
found = found + 1 if found >= 0 else -1
if found < 0:
return
x = found
self.cx = x
def set_jump(self, kind, words):
if not words or not words[0]:
raise MplexError("jump needs a character")
self.jump = (kind, words[0][0])
self.do_jump(*self.jump)
def c_jump_forward(self, words, client):
self.set_jump("f", words)
def c_jump_backward(self, words, client):
self.set_jump("F", words)
def c_jump_to_forward(self, words, client):
self.set_jump("t", words)
def c_jump_to_backward(self, words, client):
self.set_jump("T", words)
def c_jump_again(self, words, client):
if self.jump:
self.do_jump(*self.jump)
def c_jump_reverse(self, words, client):
if self.jump:
opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]]
self.do_jump(opposite, self.jump[1])
# ---- selection and copy ----------------------------------------------
def c_begin_selection(self, words, client):
self.anchor = (self.cx, self.abs_y())
self.line_select = False
def c_stop_selection(self, words, client):
return None
def c_clear_selection(self, words, client):
self.anchor = None
self.line_select = False
def c_select_line(self, words, client):
self.anchor = (0, self.abs_y())
self.line_select = True
self.rect = False
for _i in range(self.count - 1):
self.step_down(False)
self.cx = self.llen(self.abs_y())
def c_select_word(self, words, client):
ay = self.abs_y()
separators = self.separators(False)
line = self.text(ay)
if not line:
return
start = min(self.cx, len(line) - 1)
while start > 0 and self.in_ws(start, ay):
start -= 1
if self.in_ws(start, ay):
return
letters = not self.in_sep(start, ay, separators)
end = start
while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)):
start -= 1
while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)):
end += 1
self.anchor = (start, ay)
self.line_select = False
self.cx = end if self.vi_keys() else end + 1
def c_rectangle_toggle(self, words, client):
self.rect = not self.rect
def c_rectangle_on(self, words, client):
self.rect = True
def c_rectangle_off(self, words, client):
self.rect = False
def c_other_end(self, words, client):
if self.anchor is None:
return
old = (self.cx, self.abs_y())
self.goto(*self.anchor)
self.anchor = old
def c_set_mark(self, words, client):
self.mark = (self.cx, self.abs_y())
def c_jump_to_mark(self, words, client):
if self.mark is None:
return
old = (self.cx, self.abs_y())
self.goto(*self.mark)
self.mark = old
def selection_text(self):
"""The selected text. Emacs keys: the cursor stands between characters, so the end is
exclusive; vi keys: the character under the cursor is included, and so is the line end
(a newline) when the cursor stands on it. Both checked against tmux 3.6."""
if self.anchor is None:
return ""
vi = self.vi_keys()
ax, ay0 = self.anchor
cx, cy = self.cx, self.abs_y()
extra = 1 if vi else 0
if self.line_select:
lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)]
return "\n".join(lines) + ("\n" if vi else "")
if self.rect:
left, right = min(ax, cx), max(ax, cx) + extra
return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1))
(sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx)))
parts = []
for ay in range(sy, ey + 1):
line = self.text(ay)
first = sx if ay == sy else 0
last = ex + extra if ay == ey else len(line)
piece = line[first:last]
if ay == ey and vi and last > len(line):
piece += "\n"
parts.append(piece)
return "\n".join(parts)
def deliver(self, text, words, append=False):
if not text:
return
command = words[0] if words else ""
self.server.store_copy(text, command, append)
def c_copy_selection(self, words, client):
self.deliver(self.selection_text(), [])
self.c_clear_selection(words, client)
def c_copy_selection_and_cancel(self, words, client):
self.deliver(self.selection_text(), [])
return "cancel"
def c_copy_pipe(self, words, client):
self.deliver(self.selection_text(), words)
self.c_clear_selection(words, client)
def c_copy_pipe_and_cancel(self, words, client):
self.deliver(self.selection_text(), words)
return "cancel"
def c_append_selection(self, words, client):
self.deliver(self.selection_text(), [], True)
self.c_clear_selection(words, client)
def c_append_selection_and_cancel(self, words, client):
self.deliver(self.selection_text(), [], True)
return "cancel"
def copy_span(self, from_start, words, cancel):
"""copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines."""
ay = self.abs_y()
self.anchor = (0 if from_start else self.cx, ay)
self.line_select = False
self.rect = False
for _i in range(self.count - 1):
self.step_down(False)
self.cx = self.llen(self.abs_y())
self.deliver(self.selection_text(), words)
self.c_clear_selection(words, None)
return "cancel" if cancel else None
def c_copy_end_of_line(self, words, client):
return self.copy_span(False, [], False)
def c_copy_end_of_line_and_cancel(self, words, client):
return self.copy_span(False, [], True)
def c_copy_pipe_end_of_line(self, words, client):
return self.copy_span(False, words, False)
def c_copy_pipe_end_of_line_and_cancel(self, words, client):
return self.copy_span(False, words, True)
def c_copy_line(self, words, client):
return self.copy_span(True, [], False)
def c_copy_line_and_cancel(self, words, client):
return self.copy_span(True, [], True)
def c_copy_pipe_line(self, words, client):
return self.copy_span(True, words, False)
def c_copy_pipe_line_and_cancel(self, words, client):
return self.copy_span(True, words, True)
# ---- search ----------------------------------------------------------
def compile(self, text, literal=False):
flags = re.IGNORECASE if text == text.lower() else 0
if literal:
return re.compile(re.escape(text), flags)
try:
return re.compile(text, flags)
except re.error:
return None
def find(self, pattern, forward, x, ay, wrap):
"""First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line)."""
total = self.total()
def matches_of(index):
return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()]
order = range(ay, total) if forward else range(ay, -1, -1)
for index in order:
found = matches_of(index)
if index == ay:
found = [m for m in found if (m.start() >= x if forward else m.start() < x)]
if found:
match = found[0] if forward else found[-1]
return match.start(), match.end(), index
if wrap:
for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)):
found = matches_of(index)
if found:
match = found[0] if forward else found[-1]
return match.start(), match.end(), index
return None
def scroll_to(self, x, ay):
"""Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view."""
hist, rows = self.hist(), self.rows
self.cx = max(0, min(self.cols, x))
top = hist - self.oy
if top <= ay < top + rows:
self.cy = ay - top
return
gap = rows // 4
if ay < rows:
offset, self.cy = 0, ay
elif ay > hist + rows - gap:
offset, self.cy = hist, ay - hist
else:
offset, self.cy = ay + gap - rows, rows - gap
self.oy = max(0, min(hist, hist - offset))
def run_search(self, text, forward, literal=False, origin=None):
if not text:
return False
self.search = (text, forward, literal)
if self.static_rows is None:
self.pane.last_search = self.search
pattern = self.compile(text, literal)
if pattern is None:
return False
wrap = bool(self.server.pane_options(self.pane).get("wrap-search"))
vi = self.server.pane_options(self.pane).get("mode-keys") == "vi"
x, ay = origin if origin is not None else (self.cx, self.abs_y())
if forward and vi and origin is None:
x += 1
found = self.find(pattern, forward, x, ay, wrap)
if found is None:
return False
start, end, line = found
self.scroll_to(end if forward and not vi else start, line)
return True
def search_failed(self, client):
"""tmux reports a failed search as a status message, not as a command error."""
self.server.message(client, "Search failed")
def search_command(self, words, forward, literal=False, client=None):
text = words[0] if words else ""
if not text and self.search:
text = self.search[0]
if not self.run_search(text, forward, literal):
self.search_failed(client)
def c_search_forward(self, words, client):
self.search_command(words, True, False, client)
def c_search_backward(self, words, client):
self.search_command(words, False, False, client)
def c_search_forward_text(self, words, client):
self.search_command(words, True, True, client)
def c_search_backward_text(self, words, client):
self.search_command(words, False, True, client)
def c_search_again(self, words, client):
if not self.search:
return
text, forward, literal = self.search
if not self.run_search(text, forward, literal):
self.search_failed(client)
def c_search_reverse(self, words, client):
if not self.search:
return
text, forward, literal = self.search
if not self.run_search(text, not forward, literal):
self.search_failed(client)
self.search = (text, forward, literal)
def incremental(self, words, forward):
arg = words[0] if words else ""
if self.inc_origin is None:
self.inc_origin = (self.cx, self.abs_y())
mark = arg[:1]
if mark == "=":
origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1])
self.run_search(arg[1:], forward, origin=origin)
elif mark in ("+", "-"):
self.run_search(arg[1:], mark == "+")
else:
self.inc_origin = None
if arg:
self.search = (arg, forward, False)
def c_search_forward_incremental(self, words, client):
self.incremental(words, True)
def c_search_backward_incremental(self, words, client):
self.incremental(words, False)
def search_cancel(self):
if self.inc_origin is not None:
self.goto(*self.inc_origin)
self.inc_origin = None
self.server.mark_dirty(self.pane.window)
class MplexPrompt:
"""The command line in the status row: emacs-style editing, history, and the
single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants."""
def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False,
on_change=None, on_cancel=None, on_step=None, confirm=False):
self.label = label
self.text = text
self.pos = len(text)
self.on_done = on_done
self.history = history if history is not None else []
self.hist_index = None
self.single = single
self.numeric = numeric
self.key_name = key_name
self.on_change = on_change
self.on_cancel = on_cancel
self.on_step = on_step
self.confirm = confirm
def view(self):
return self.label + self.text, len(self.label) + self.pos
def finish(self, server, client, value):
client.prompt = None
if value and not (self.single or self.numeric or self.key_name or self.confirm):
self.history.append(value)
del self.history[:-MPLEX_PROMPT_HISTORY_CAP]
self.on_done(value)
def cancel(self, client):
client.prompt = None
if self.on_cancel:
self.on_cancel()
def changed(self):
if self.on_change:
self.on_change(self.text)
@staticmethod
def printable(name, raw):
if name == "Space":
return " "
if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable():
return name
return ""
def key(self, server, client, name, raw):
char = self.printable(name, raw)
if self.confirm:
if char == "y":
self.finish(server, client, "y")
else:
self.cancel(client)
return
if self.key_name:
self.finish(server, client, name)
return
if self.single:
if char:
self.finish(server, client, char)
elif name in ("Escape", "C-c", "C-g"):
self.cancel(client)
return
if self.numeric:
if char.isdigit():
self.text += char
self.pos = len(self.text)
return
self.finish(server, client, self.text)
client.redispatch(name, raw)
return
text, pos = self.text, self.pos
if name in ("Enter", "C-j"):
self.finish(server, client, text)
return
if name in ("Escape", "C-c", "C-g"):
self.cancel(client)
return
if name in ("C-a", "Home"):
pos = 0
elif name in ("C-e", "End"):
pos = len(text)
elif name in ("C-b", "Left"):
pos = max(0, pos - 1)
elif name in ("C-f", "Right"):
pos = min(len(text), pos + 1)
elif name == "M-b":
while pos > 0 and text[pos - 1] == " ":
pos -= 1
while pos > 0 and text[pos - 1] != " ":
pos -= 1
elif name == "M-f":
while pos < len(text) and text[pos] == " ":
pos += 1
while pos < len(text) and text[pos] != " ":
pos += 1
elif name in ("BSpace", "C-h"):
if pos > 0:
text = text[:pos - 1] + text[pos:]
pos -= 1
elif name in ("DC", "C-d"):
text = text[:pos] + text[pos + 1:]
elif name == "C-k":
text = text[:pos]
elif name == "C-u":
text = text[pos:]
pos = 0
elif name == "C-w":
start = pos
while start > 0 and text[start - 1] == " ":
start -= 1
while start > 0 and text[start - 1] != " ":
start -= 1
text = text[:start] + text[pos:]
pos = start
elif name == "C-y":
piece = server.top_buffer_text()
text = text[:pos] + piece + text[pos:]
pos += len(piece)
elif name == "C-t":
if 0 < pos < len(text):
text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:]
pos += 1
elif name in ("Up", "C-p", "Down", "C-n"):
if self.history:
if name in ("Up", "C-p"):
self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1)
elif self.hist_index is not None:
self.hist_index += 1
if self.hist_index is not None and self.hist_index >= len(self.history):
self.hist_index = None
text = ""
elif self.hist_index is not None:
text = self.history[self.hist_index]
pos = len(text)
elif name in ("C-s", "C-r") and self.on_step:
self.on_step(name, text)
return
elif char:
text = text[:pos] + char + text[pos:]
pos += len(char)
else:
return
edited = text != self.text
self.text, self.pos = text, pos
if edited:
self.changed()
class MplexFont:
GLYPHS = {
"0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."),
"2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"),
"4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"),
"6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"),
"8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"),
":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"),
"P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"),
}
HEIGHT = 5
@classmethod
def width(cls, text):
return len(text) * 6 - 1 if text else 0
@classmethod
def draw(cls, text, bg):
"""Five rows of cells for text in big glyphs, lit pixels painted with background bg."""
rows = []
lit = (" ", None, bg, 0)
dark = MplexCanvas.BLANK
for line in range(cls.HEIGHT):
row = []
for index, char in enumerate(text):
glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"])
row.extend(lit if pixel == "#" else dark for pixel in glyph[line])
if index != len(text) - 1:
row.append(dark)
rows.append(row)
return rows
class MplexClockMode(MplexMode):
name = "clock-mode"
def render(self, client):
opts = self.server.pane_options(self.pane)
bg = MplexStyle.color(opts.get("clock-mode-colour"), True)
now = time.localtime()
text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now)
grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)]
if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text):
big = MplexFont.draw(text, bg)
top = (self.pane.h - MplexFont.HEIGHT) // 2
left = (self.pane.w - MplexFont.width(text)) // 2
for index, row in enumerate(big):
grid[top + index][left:left + len(row)] = row
else:
top = self.pane.h // 2
left = max(0, (self.pane.w - len(text)) // 2)
for index, char in enumerate(text[:self.pane.w]):
grid[top][left + index] = (char, None, bg, 0)
return grid, None
def key(self, name, raw, client):
self.close()
return True
class MplexPaneNumbers:
"""display-panes overlay: a big index on every pane until a key or the timer ends it."""
def __init__(self, server, window, until):
self.server = server
self.window = window
self.until = until
def key(self, name, raw, client):
client.overlay = None
if len(name) == 1 and name.isdigit():
base = self.server.pane_options(self.window.active).get("pane-base-index")
panes = self.window.panes()
index = int(name) - base
if 0 <= index < len(panes):
self.server.select_pane(self.window, panes[index])
self.server.mark_dirty(self.window)
def draw(self, canvas, area_y):
opts = self.window.options
base = opts.get("pane-base-index")
normal = MplexStyle.color(opts.get("display-panes-colour"), True)
active = MplexStyle.color(opts.get("display-panes-active-colour"), True)
for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()):
bg = active if pane is self.window.active else normal
text = str(index + base)
if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text):
big = MplexFont.draw(text, bg)
top = pane.y + (pane.h - MplexFont.HEIGHT) // 2
left = pane.x + (pane.w - MplexFont.width(text)) // 2
for offset, row in enumerate(big):
canvas.put_cells(left, area_y + top + offset, row)
else:
canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0))
size = "%dx%d" % (pane.w, pane.h)
if pane.w > len(size) + 1:
canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0))
class MplexChoice:
__slots__ = ("label", "payload", "children", "expanded", "depth", "tag")
def __init__(self, label, payload=None, children=None, expanded=False):
self.label = label
self.payload = payload
self.children = children or []
self.expanded = expanded
self.depth = 0
self.tag = False
class MplexChooser(MplexMode):
"""choose-tree / choose-buffer / choose-client: a list you move through, with an
optional live preview of the selected item below it."""
name = "tree-mode"
HOT_KEYS = "0123456789"
def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None):
super().__init__(server, pane)
self.name = kind
self.builder = builder
self.items = builder()
self.on_close = on_close
self.on_choose = on_choose
self.on_delete = on_delete
self.preview = preview
self.show_preview = preview is not None
self.cursor = 0
self.visible = []
self.reflatten()
if selected is not None:
for index, item in enumerate(self.visible):
if item.payload == selected:
self.cursor = index
def reflatten(self):
self.visible = []
def walk(nodes, depth):
for node in nodes:
node.depth = depth
self.visible.append(node)
if node.children and node.expanded:
walk(node.children, depth + 1)
walk(self.items, 0)
self.cursor = max(0, min(len(self.visible) - 1, self.cursor))
def selected(self):
return self.visible[self.cursor] if self.visible else None
def rebuild(self):
opened = set()
def collect(nodes):
for node in nodes:
if node.expanded:
opened.add(repr(node.payload))
collect(node.children)
def restore(nodes):
for node in nodes:
node.expanded = node.expanded or repr(node.payload) in opened
restore(node.children)
collect(self.items)
self.items = self.builder()
restore(self.items)
self.reflatten()
if not self.visible:
self.close()
def close(self):
super().close()
if self.on_close is not None:
callback, self.on_close = self.on_close, None
callback()
def list_height(self):
if self.show_preview and self.pane.h >= 8:
return max(3, self.pane.h // 2)
return self.pane.h
def render(self, client):
w, h = self.pane.w, self.pane.h
opts = self.server.pane_options(self.pane)
mode_style = MplexStyle.parse(opts.get("mode-style"))
grid = [[MplexCanvas.BLANK] * w for _row in range(h)]
list_h = self.list_height()
top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0
for row in range(list_h):
index = top + row
if index >= len(self.visible):
break
node = self.visible[index]
hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " "
marker = ("- " if node.expanded else "+ ") if node.children else " "
line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w)
style = mode_style if index == self.cursor else (None, None, 0)
grid[row] = [(char, style[0], style[1], style[2]) for char in line]
if self.show_preview and list_h < h:
border = MplexCanvas.GLYPHS["single"][0]
grid[list_h] = [(border, None, None, 0)] * w
chosen = self.selected()
if chosen is not None:
block = self.preview(chosen, w, h - list_h - 1) or []
for offset, cells in enumerate(block[:h - list_h - 1]):
grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w]
return grid, None
def move(self, delta):
self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta))
def key(self, name, raw, client):
node = self.selected()
page = max(1, self.list_height() - 1)
if name in ("q", "Escape", "C-c"):
self.close()
elif name in ("Up", "k", "C-p"):
self.move(-1)
elif name in ("Down", "j", "C-n"):
self.move(1)
elif name in ("PPage", "C-b"):
self.move(-page)
elif name in ("NPage", "C-f"):
self.move(page)
elif name in ("Home", "g"):
self.cursor = 0
elif name in ("End", "G"):
self.cursor = max(0, len(self.visible) - 1)
elif name in ("Right", "l", "+") and node is not None and node.children:
node.expanded = True
self.reflatten()
elif name in ("Left", "h", "-") and node is not None:
if node.children and node.expanded:
node.expanded = False
else:
for index in range(self.cursor - 1, -1, -1):
if self.visible[index].depth < node.depth:
self.cursor = index
break
self.reflatten()
elif name == "t" and node is not None:
node.tag = not node.tag
elif name == "v" and self.preview is not None:
self.show_preview = not self.show_preview
elif name == "x" and node is not None and self.on_delete is not None:
self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client))
elif name == "Enter" and node is not None:
self.close()
self.on_choose(node, client)
elif len(name) == 1 and name in self.HOT_KEYS:
index = self.HOT_KEYS.index(name)
if index < len(self.visible):
self.cursor = index
self.close()
self.on_choose(self.visible[index], client)
self.server.mark_dirty(self.pane.window)
return True
def delete(self, node, client):
self.on_delete(node, client)
self.server.refresh_choosers()
class MplexRenderer:
"""Composes one client's whole screen: panes, borders, status row, prompt, overlays."""
def __init__(self, server):
self.server = server
def block_of(self, pane, client):
mode = pane.top_mode()
if mode is not None:
return mode.render(client)
grid = pane.screen.grid
blank = MplexCanvas.BLANK
block = [[cell if cell is not None else blank for cell in row] for row in grid]
cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None
return block, cursor
def frame(self, client):
server = self.server
canvas = MplexCanvas(client.rows, client.cols)
session = client.session
window = session.window()
opts = window.options
status_on = bool(opts.get("status"))
on_top = opts.get("status-position") == "top"
area_y = 1 if (status_on and on_top) else 0
cursor = None
active = window.active
for pane in window.visible_panes():
block, pane_cursor = self.block_of(pane, client)
canvas.blit(pane.x, area_y + pane.y, block, pane.w)
if pane is active and pane_cursor is not None:
cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1])
if window.zoomed is None and len(window.panes()) > 1:
self.borders(canvas, window, area_y)
if isinstance(client.overlay, MplexPaneNumbers):
client.overlay.draw(canvas, area_y)
cursor = None
status_row = 0 if on_top else client.rows - 1
if status_on:
self.status(canvas, client, session, window, status_row)
line_row = status_row if status_on else client.rows - 1
if client.prompt is not None:
text, pos = client.prompt.view()
style = MplexStyle.parse(opts.get("message-style"))
canvas.fill(0, line_row, client.cols, 1, style)
canvas.put(0, line_row, text, style, client.cols)
cursor = (min(client.cols - 1, pos), line_row)
elif client.message is not None and time.monotonic() < client.message[1]:
style = MplexStyle.parse(opts.get("message-style"))
canvas.fill(0, line_row, client.cols, 1, style)
canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols)
canvas.cursor = cursor
return canvas
def borders(self, canvas, window, area_y):
W, H = window.width, window.height
panes = window.panes()
cells = set()
for pane in panes:
if pane.x + pane.w < W:
for row in range(pane.y, min(H, pane.y + pane.h + 1)):
cells.add((pane.x + pane.w, row))
if pane.y + pane.h < H:
for col in range(pane.x, min(W, pane.x + pane.w + 1)):
cells.add((col, pane.y + pane.h))
active = window.active
hot = set()
if active is not None:
for col in range(active.x - 1, active.x + active.w + 1):
hot.add((col, active.y - 1))
hot.add((col, active.y + active.h))
for row in range(active.y - 1, active.y + active.h + 1):
hot.add((active.x - 1, row))
hot.add((active.x + active.w, row))
hot &= cells
opts = window.options
lines = opts.get("pane-border-lines")
plain = MplexStyle.parse(opts.get("pane-border-style"))
hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active))
hot_style = MplexStyle.parse(hot_text, plain)
for (x, y) in cells:
style = hot_style if (x, y) in hot else plain
glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines)
canvas.put(x, area_y + y, glyph, style)
def status(self, canvas, client, session, window, row):
server = self.server
opts = window.options
base = MplexStyle.parse(opts.get("status-style"))
canvas.fill(0, row, client.cols, 1, base)
resolver = server.resolver_for(session, window, window.active, client)
left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)),
MplexStyle.parse(opts.get("status-left-style"), base), base)
right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)),
MplexStyle.parse(opts.get("status-right-style"), base), base)
left = left[:opts.get("status-left-length")]
right = right[:opts.get("status-right-length")]
if len(left) > client.cols:
left = left[:client.cols]
room_right = max(0, client.cols - len(left))
right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right]
avail = client.cols - len(left) - len(right)
entries = []
current_at = 0
total = 0
last_index = session.last
separator = opts.get("window-status-separator")
for index in session.indexes():
item = session.windows[index]
wres = server.resolver_for(session, item, item.active, client)
is_current = index == session.current
fmt = item.options.get("window-status-current-format" if is_current else "window-status-format")
style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style")
style = MplexStyle.parse(item.options.get(style_name), base)
cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style)
if entries:
cells = [(char, base[0], base[1], base[2]) for char in separator] + cells
if is_current:
current_at = total + (len(separator) if entries else 0)
entries.append(cells)
total += len(cells)
listing = [cell for cells in entries for cell in cells]
if len(listing) > avail:
listing = self.fit_list(listing, avail, current_at, base)
justify = opts.get("status-justify")
offset = 0
if justify in ("centre", "center"):
offset = max(0, (avail - len(listing)) // 2)
elif justify == "right":
offset = max(0, avail - len(listing))
canvas.put_cells(0, row, left)
canvas.put_cells(len(left) + offset, row, listing)
canvas.put_cells(client.cols - len(right), row, right)
@staticmethod
def fit_list(cells, avail, current_at, base):
"""The slice of the window list around the current window, with < and > markers."""
if avail <= 0:
return []
start = max(0, min(current_at - avail // 3, len(cells) - avail))
piece = cells[start:start + avail]
marker = lambda char: (char, base[0], base[1], base[2] | 1)
if start > 0 and piece:
piece[0] = marker("<")
if start + avail < len(cells) and piece:
piece[-1] = marker(">")
return piece
class MplexCtx:
"""What a command runs against: the issuing client and its current session, window, pane."""
def __init__(self, server, client=None, pane=None, interactive=False):
self.server = server
self.client = client
self.interactive = interactive
self.depth = 0
self.pane = pane
self.window = pane.window if pane is not None else None
self.session = self.window.session if self.window is not None else None
if client is not None and client.session is not None and pane is None:
self.session = client.session
self.window = self.session.window()
self.pane = self.window.active if self.window is not None else None
elif self.session is None and client is not None and client.env_pane is not None:
found = server.panes.get(client.env_pane)
if found is not None and found.window is not None:
self.pane = found
self.window = found.window
self.session = found.window.session
if self.session is None:
self.session = server.latest_session()
if self.session is not None:
self.window = self.session.window()
self.pane = self.window.active if self.window is not None else None
class MplexTargets:
"""tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session."""
@staticmethod
def session(ctx, spec):
server = ctx.server
if not spec:
if ctx.session is None:
raise MplexError("no current session")
return ctx.session
found = None
if spec.startswith("$") and spec[1:].isdigit():
found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None)
else:
spec = spec.lstrip("=")
found = next((s for s in server.sessions.values() if s.name == spec), None)
if found is None:
matches = [s for s in server.sessions.values() if s.name.startswith(spec)]
if len(matches) == 1:
found = matches[0]
if found is None:
import fnmatch
matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)]
if len(matches) == 1:
found = matches[0]
if found is None:
raise MplexError("can't find session: %s" % spec)
return found
@classmethod
def window(cls, ctx, spec):
"""Returns (session, index, window)."""
server = ctx.server
spec = spec or ""
if spec.startswith("@") and spec[1:].isdigit():
window = server.windows.get(int(spec[1:]))
if window is None:
raise MplexError("can't find window: %s" % spec)
return window.session, window.session.index_of(window), window
if ":" in spec:
sess_part, _sep, win_part = spec.partition(":")
session = cls.session(ctx, sess_part)
else:
sess_part, win_part, session = "", spec, ctx.session
if session is None:
raise MplexError("no current session")
index = cls.window_index(session, win_part)
if index is None and ":" not in spec and win_part:
try:
session = cls.session(ctx, win_part)
index = session.current
except MplexError:
index = None
if index is None or index not in session.windows:
raise MplexError("can't find window: %s" % (win_part or spec))
return session, index, session.windows[index]
@staticmethod
def window_index(session, text):
indexes = session.indexes()
if not indexes:
return None
text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text
if text == "":
return session.current
if text in ("!", "{last}"):
return session.last
if text in ("^", "{start}"):
return indexes[0]
if text in ("$", "{end}"):
return indexes[-1]
if text == "{next}":
text = "+"
if text == "{previous}":
text = "-"
if text[0] in "+-":
step = int(text[1:]) if text[1:].isdigit() else 1
here = indexes.index(session.current) if session.current in indexes else 0
return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)]
if text.isdigit():
return int(text)
for index in indexes:
if session.windows[index].name == text:
return index
matches = [index for index in indexes if session.windows[index].name.startswith(text)]
if len(matches) == 1:
return matches[0]
import fnmatch
matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)]
return matches[0] if len(matches) == 1 else None
@classmethod
def pane(cls, ctx, spec):
"""Returns (session, window, pane)."""
server = ctx.server
spec = spec or ""
if spec.startswith("%") and spec[1:].isdigit():
pane = server.panes.get(int(spec[1:]))
if pane is None or pane.window is None:
raise MplexError("can't find pane: %s" % spec)
return pane.window.session, pane.window, pane
if ":" in spec:
sess_part, _sep, rest = spec.partition(":")
if "." in rest:
win_part, _dot, pane_part = rest.rpartition(".")
else:
win_part, pane_part = rest, ""
ctx_for_window = sess_part + ":" + win_part
session, _index, window = cls.window(ctx, ctx_for_window)
elif "." in spec:
win_part, _dot, pane_part = spec.rpartition(".")
session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window)
else:
pane_part = spec
session, window = ctx.session, ctx.window
if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"):
try:
session, _index, window = cls.window(ctx, pane_part)
pane_part = ""
except MplexError:
raise MplexError("can't find pane: %s" % spec)
if window is None:
raise MplexError("no current window")
panes = window.panes()
if not pane_part:
return session, window, window.active
base = window.options.get("pane-base-index")
if pane_part.isdigit():
index = int(pane_part) - base
if 0 <= index < len(panes):
return session, window, panes[index]
raise MplexError("can't find pane: %s" % pane_part)
here = panes.index(window.active) if window.active in panes else 0
if pane_part in ("!", "{last}"):
if window.last_pane in panes:
return session, window, window.last_pane
raise MplexError("can't find pane: %s" % pane_part)
if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"):
sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+"
amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1
return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)]
edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height,
"{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width,
"{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width,
"{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0,
"{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width}
if pane_part in edges:
for pane in panes:
if edges[pane_part](pane):
return session, window, pane
raise MplexError("can't find pane: %s" % pane_part)
class MplexClientConn:
"""Server-side state of one connection: framing, key table position, prompt, overlay, last frame."""
def __init__(self, server, sock, ident):
self.server = server
self.sock = sock
self.id = ident
self.inbuf = bytearray()
self.out = bytearray()
self.rows = 24
self.cols = 80
self.session = None
self.attached = False
self.table = "root"
self.repeating = False
self.repeat_until = 0.0
self.keybuf = b""
self.esc_deadline = None
self.prompt = None
self.message = None
self.overlay = None
self.prev = None
self.cursor = None
self.dirty = True
self.last_render = 0.0
self.pid = 0
self.tty = ""
self.term = ""
self.cwd = os.getcwd()
self.env = {}
self.env_pane = None
self.closing = False
self.created = time.time()
def send(self, kind, payload=b""):
if len(self.out) > MPLEX_CLIENT_OUT_MAX:
self.prev = None
return False
self.out += kind + struct.pack(">I", len(payload)) + payload
return True
def frames(self, data):
"""Complete (kind, payload) frames in what arrived; None when the stream is corrupt."""
self.inbuf += data
found = []
while len(self.inbuf) >= 5:
length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0]
if length > MPLEX_FRAME_MAX:
return None
if len(self.inbuf) < 5 + length:
break
found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length])))
del self.inbuf[:5 + length]
return found
def redispatch(self, name, raw):
self.server.handle_key(self, name, raw)
def flush(self):
if not self.out:
return
try:
sent = self.sock.send(bytes(self.out[:65536]))
del self.out[:sent]
except (BlockingIOError, InterruptedError):
pass
except OSError:
self.out.clear()
self.closing = True
def close(self):
try:
self.sock.close()
except OSError:
pass
class MplexServer:
"""Owns every session, window, pane and client. One select loop, no threads."""
def __init__(self, home):
self.home = home
self.dir = plexer_dir(home)
self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME)
self.config = MplexConfig.load(MplexConfig.default_path(home))
self.options = self.config.options
self.sessions = {}
self.windows = {}
self.panes = {}
self.clients = []
self.conns = []
self.buffers = []
self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX)
self.prompt_history = {}
self.timers = []
self.reaping = []
self.marked = None
self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0}
self.had_session = False
self.running = False
self.listener = None
self.renderer = MplexRenderer(self)
self.commands = MplexCommands(self)
self.shell_cache = {}
self.started = time.monotonic()
self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
self.next_tick = 0.0
self.notice = None
for problem in self.config.errors:
self.log_message("mplex.toml: " + problem)
if self.config.skipped:
self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped
# ---- bookkeeping -----------------------------------------------------
def next_id(self, kind):
value = self.ids[kind]
self.ids[kind] += 1
return value
def log_message(self, text):
self.messages.append((time.time(), text))
def latest_session(self):
if not self.sessions:
return None
return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id))
def attached_clients(self, session=None):
return [c for c in self.clients if c.attached and (session is None or c.session is session)]
def pane_options(self, pane):
return pane.window.options if pane.window is not None else self.options
def message(self, client, text, milliseconds=None):
self.log_message(text)
if client is None:
return
if milliseconds is None:
milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time")
client.message = (text, time.monotonic() + milliseconds / 1000.0)
client.dirty = True
self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True))
def mark_dirty(self, window=None):
for client in self.clients:
if client.attached and (window is None or client.session.window() is window):
client.dirty = True
def mark_all_dirty(self):
for client in self.clients:
client.dirty = True
def add_timer(self, deadline, callback):
if len(self.timers) < 1024:
self.timers.append((deadline, callback))
# ---- buffers and copy ------------------------------------------------
def add_buffer(self, text, name=None):
if name is None:
name = "buffer%d" % self.next_id("buffer")
self.buffers = [item for item in self.buffers if item["name"] != name]
self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]})
del self.buffers[self.options.get("buffer-limit"):]
return name
def top_buffer_text(self):
return self.buffers[0]["data"] if self.buffers else ""
def find_buffer(self, name):
if not name:
if not self.buffers:
raise MplexError("no buffers")
return self.buffers[0]
for item in self.buffers:
if item["name"] == name:
return item
raise MplexError("unknown buffer: %s" % name)
def run_shell_input(self, command, text):
try:
done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT)
if done.returncode != 0:
raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200])
except (OSError, subprocess.SubprocessError) as exc:
raise MplexError("copy command failed: " + short_error(exc))
def store_copy(self, text, command="", append=False):
if append and self.buffers:
self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX]
text = self.buffers[0]["data"]
else:
self.add_buffer(text)
pipe = command or self.options.get("copy-command")
if pipe:
self.run_shell_input(pipe, text)
if self.options.get("set-clipboard") != "off":
sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07"
for client in self.attached_clients():
client.send(b"O", sequence.encode("ascii"))
# ---- sizing ----------------------------------------------------------
def resize_session(self, session):
sizes = [(c.cols, c.rows) for c in self.attached_clients(session)]
if sizes:
session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes))
elif not hasattr(session, "size"):
session.size = (80, 24)
cols, rows = session.size
for window in session.windows.values():
window.width = max(1, cols)
window.height = max(1, rows - (1 if window.options.get("status") else 0))
window.relayout()
self.mark_dirty()
def resize_all(self):
for session in self.sessions.values():
self.resize_session(session)
# ---- model: panes, windows, sessions ---------------------------------
def default_shell(self, options):
return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh"
def resolve_term(self, name):
wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"]
roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"]
for term in wanted:
for root in roots:
if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))):
return term
return "xterm"
def spawn_pane(self, window, width, height, words, cwd, env_extra=None):
if len(self.panes) >= MPLEX_MAX_PANES:
raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES)
session = window.session
options = window.options
pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp)
shell = self.default_shell(options)
if words:
file = shell if len(words) == 1 else words[0]
argv = [shell, "-c", words[0]] if len(words) == 1 else list(words)
elif options.get("default-command"):
file, argv = shell, [shell, "-c", options.get("default-command")]
else:
file, argv = shell, ["-" + os.path.basename(shell)]
env = dict(session.env)
env.pop("LINES", None)
env.pop("COLUMNS", None)
env["TERM"] = self.resolve_term(options.get("default-terminal"))
env[PLEXER_ACTIVE_ENV] = "1"
env[PLEXER_SOCKET_ENV] = self.sock_path
env[PLEXER_PANE_ENV] = str(pane.id)
env.update(env_extra or {})
pane.window = window
pane.spawn(file, argv, cwd or session.cwd, env)
self.panes[pane.id] = pane
return pane
def register_window(self, session, window, index, select):
window.session = session
window.options.parent = session.options
session.windows[index] = window
self.windows[window.id] = window
if select or session.current is None:
session.select(index)
def create_window(self, session, name, cwd, words, env_extra, index, select):
if len(self.windows) >= MPLEX_MAX_WINDOWS:
raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS)
cols, rows = getattr(session, "size", (80, 24))
window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options)
window.session = session
pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra)
window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height)
window.active = pane
pane.last_active = time.monotonic()
window.auto_rename = not name
if not name:
window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0])
self.register_window(session, window, index, select)
window.relayout()
self.mark_dirty()
return window
def create_session(self, name, cwd, env, words, window_name, size, index_hint=None):
if len(self.sessions) >= MPLEX_MAX_SESSIONS:
raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS)
session = MplexSession(self.next_id("session"), name, self.options, cwd, env)
session.size = size
self.sessions[session.id] = session
self.had_session = True
try:
self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True)
except Exception:
self.sessions.pop(session.id, None)
raise
return session
def auto_session_name(self):
taken = {s.name for s in self.sessions.values()}
number = 0
while str(number) in taken:
number += 1
return str(number)
def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select):
leaf = window.leaf_of(target)
if leaf is None:
raise MplexError("pane is not in this window")
if window.zoomed is not None:
window.zoomed = None
window.relayout()
leaf = window.leaf_of(target)
span = leaf.w if kind == "lr" else leaf.h
if span < 3:
raise MplexError("no space for new pane")
pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra)
try:
window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size)
except MplexError:
self.discard_pane(pane)
raise
window.layout_index = -1
window.relayout()
if select:
window.set_active(pane)
self.mark_dirty(window)
return pane
def discard_pane(self, pane):
pane.close()
self.panes.pop(pane.id, None)
if pane.pid is not None:
self.reaping.append((pane.pid, time.monotonic()))
pane.pid = None
def select_pane(self, window, pane):
if window.zoomed is not None and pane is not window.zoomed:
window.zoomed = None
window.relayout()
window.set_active(pane)
self.mark_dirty(window)
def kill_pane(self, pane):
window = pane.window
if window is None:
return
if len(window.panes()) == 1:
self.kill_window(window)
return
was_active = window.active is pane
window.root = MplexLayout.remove(window.root, window.leaf_of(pane))
if window.zoomed is pane:
window.zoomed = None
if self.marked is pane:
self.marked = None
self.discard_pane(pane)
rest = window.panes()
if window.last_pane is pane:
window.last_pane = None
if was_active:
window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active)
window.last_pane = None
window.layout_index = -1
window.relayout()
self.mark_dirty(window)
def kill_window(self, window):
session = window.session
index = session.index_of(window)
for pane in window.panes():
if self.marked is pane:
self.marked = None
self.discard_pane(pane)
self.windows.pop(window.id, None)
if index is not None:
session.windows.pop(index, None)
if not session.windows:
self.kill_session(session)
return
session.history = [item for item in session.history if item is not window]
if session.current == index:
nxt = session.last
if nxt is None:
lower = [i for i in session.indexes() if i < index]
nxt = lower[-1] if lower else session.indexes()[-1]
session.current = nxt
session.history = [item for item in session.history if item is not session.windows[nxt]]
if session.options.get("renumber-windows"):
self.renumber(session)
self.mark_dirty()
def renumber(self, session):
base = self.options.get("base-index")
current = session.window()
ordered = [session.windows[i] for i in session.indexes()]
session.windows = {base + n: window for n, window in enumerate(ordered)}
session.current = session.index_of(current) if current else None
def kill_session(self, session):
for window in list(session.windows.values()):
for pane in window.panes():
if self.marked is pane:
self.marked = None
self.discard_pane(pane)
self.windows.pop(window.id, None)
session.windows.clear()
self.sessions.pop(session.id, None)
for client in list(self.attached_clients(session)):
others = [s for s in self.sessions.values()]
if others and not self.options.get("detach-on-destroy"):
self.attach_client(client, self.latest_session())
else:
self.detach_client(client, "[exited]", b"X")
self.mark_dirty()
def attach_client(self, client, session):
old = client.session
client.session = session
session.last_attached = time.time()
client.table = "root"
client.prompt = None
client.overlay = None
client.prev = None
if not client.attached:
client.attached = True
client.send(b"A")
if old is not None and old is not session:
self.resize_session(old)
self.resize_session(session)
client.dirty = True
if self.notice:
self.message(client, self.notice, 4000)
self.notice = None
def detach_client(self, client, text="", kind=b"D"):
old = client.session
client.send(kind, text.encode("utf-8"))
client.attached = False
client.session = None
client.closing = True
if old is not None and old.id in self.sessions:
self.resize_session(old)
# ---- formats ---------------------------------------------------------
def window_flags(self, session, window):
flags = ""
index = session.index_of(window)
if index == session.current:
flags += "*"
elif index == session.last:
flags += "-"
if window.zoomed is not None:
flags += "Z"
if self.marked is not None and self.marked.window is window:
flags += "M"
return flags
def resolver_for(self, session, window, pane, client=None):
server = self
def get(name):
if name.startswith("("):
return server.shell_output(name[1:])
return server.variable(name, session, window, pane, client)
return get
def shell_output(self, command):
now = time.monotonic()
cached = self.shell_cache.get(command)
interval = max(1, self.options.get("status-interval"))
if cached and now - cached[0] < interval:
return cached[1]
try:
done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT)
text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0]
except (OSError, subprocess.SubprocessError):
text = ""
if len(self.shell_cache) > 64:
self.shell_cache.clear()
self.shell_cache[command] = (now, text)
return text
def variable(self, name, session, window, pane, client):
if name in MplexOptions.DEFS:
scope = window.options if window is not None else (session.options if session is not None else self.options)
value = scope.get(name)
if isinstance(value, bool):
return "1" if value else "0"
return str(value)
if name == "host":
return socket.gethostname()
if name == "host_short":
return socket.gethostname().split(".")[0]
if name == "version":
return VERSION
if session is not None:
if name == "session_name":
return session.name
if name == "session_id":
return session.tag
if name == "session_windows":
return str(len(session.windows))
if name == "session_created":
return str(int(session.created))
if name == "session_attached":
return str(len(self.attached_clients(session)))
if window is not None and session is not None:
index = session.index_of(window)
if name == "window_index":
return str(index)
if name == "window_name":
return window.name
if name == "window_id":
return window.tag
if name == "window_flags":
return self.window_flags(session, window)
if name == "window_active":
return "1" if index == session.current else "0"
if name == "window_last_flag":
return "1" if index == session.last else "0"
if name == "window_panes":
return str(len(window.panes()))
if name == "window_zoomed_flag":
return "1" if window.zoomed is not None else "0"
if name == "window_width":
return str(window.width)
if name == "window_height":
return str(window.height)
if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"):
return "0"
if name == "window_marked_flag":
return "1" if self.marked is not None and self.marked.window is window else "0"
if pane is not None:
mode = pane.top_mode()
screen = pane.screen
if name == "pane_id":
return pane.tag
if name == "pane_index":
return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0"
if name == "pane_title":
return pane.title
if name == "pane_current_command":
return pane.foreground_command()
if name == "pane_current_path":
return pane.current_path()
if name == "pane_pid":
return str(pane.pid or "")
if name == "pane_width":
return str(pane.w)
if name == "pane_height":
return str(pane.h)
if name == "pane_active":
return "1" if pane.window is not None and pane.window.active is pane else "0"
if name == "pane_last":
return "1" if pane.window is not None and pane.window.last_pane is pane else "0"
if name == "pane_in_mode":
return "1" if mode is not None else "0"
if name == "pane_mode":
return mode.name if mode is not None else ""
if name == "pane_dead":
return "1" if pane.dead else "0"
if name == "pane_dead_status":
return "" if pane.exit_status is None else str(pane.exit_status)
if name == "pane_left":
return str(pane.x)
if name == "pane_top":
return str(pane.y)
if name == "pane_right":
return str(pane.x + pane.w - 1)
if name == "pane_bottom":
return str(pane.y + pane.h - 1)
if name == "pane_synchronized":
return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0"
if name == "pane_marked":
return "1" if self.marked is pane else "0"
if name == "pane_marked_set":
return "1" if self.marked is not None else "0"
if name == "alternate_on":
return "1" if screen.alt_active else "0"
if name == "history_size":
return str(pane.history_size())
if name == "history_limit":
return str(screen.history.maxlen if screen.history is not None else 0)
if name == "cursor_x":
return str(screen.cx)
if name == "cursor_y":
return str(screen.cy)
if name == "pane_search_string":
return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else ""
if isinstance(mode, MplexCopyMode):
if name == "scroll_position":
return str(mode.oy)
if name == "copy_cursor_x":
return str(mode.cx)
if name == "copy_cursor_y":
return str(mode.cy)
if name == "selection_present":
return "1" if mode.anchor is not None else "0"
if name == "copy_cursor_line":
return mode.text(mode.abs_y())
if name == "copy_cursor_word":
line = mode.text(mode.abs_y())
if not line:
return ""
x = min(mode.cx, len(line) - 1)
seps = pane.window.options.get("word-separators") + " "
start = end = x
while start > 0 and line[start - 1] not in seps:
start -= 1
while end + 1 < len(line) and line[end + 1] not in seps:
end += 1
return line[start:end + 1]
if name == "scroll_position":
return ""
if client is not None:
if name == "client_width":
return str(client.cols)
if name == "client_height":
return str(client.rows)
if name == "client_session":
return client.session.name if client.session else ""
if name == "client_tty":
return client.tty
if name == "client_termname":
return client.term
if name == "client_pid":
return str(client.pid)
if name == "client_key_table":
return client.table
if name == "client_prefix":
return "1" if client.table == "prefix" else "0"
if name == "client_created":
return str(int(client.created))
return None
def expand(self, text, ctx, extra=None):
"""Format-expand text for a command context (strftime applied)."""
base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client)
if extra:
def resolve(name):
return extra[name] if name in extra else base(name)
else:
resolve = base
return MplexFormat.with_time(MplexFormat.expand(text, resolve))
# ---- key handling ----------------------------------------------------
def is_prefix(self, session, name):
opts = session.options
return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None"
def input_bytes(self, client, data):
client.keybuf = (client.keybuf + data)[-65536:]
self.process_keys(client, False)
def process_keys(self, client, final):
tokens, rest = MplexKeys.split(client.keybuf, final)
client.keybuf = rest
for name, raw in tokens:
if not client.attached:
break
self.handle_key(client, name, raw)
if client.keybuf and client.attached:
client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0
else:
client.esc_deadline = None
def handle_key(self, client, name, raw):
if not client.attached:
return
client.dirty = True
if client.prompt is not None:
client.prompt.key(self, client, name, raw)
return
if client.overlay is not None:
client.overlay.key(name, raw, client)
return
client.message = None
session = client.session
window = session.window()
pane = window.active
if not name:
self.forward(pane, window, raw)
return
table = client.table
if table == "root":
binding = self.config.lookup("root", name)
if binding is not None:
self.run_binding(client, binding, pane)
return
if self.is_prefix(session, name):
client.table = "prefix"
client.repeating = False
return
mode = pane.top_mode()
if mode is not None:
mode.key(name, raw, client)
return
self.forward(pane, window, raw)
return
binding = self.config.lookup(table, name)
if client.repeating and not (binding is not None and binding.repeat):
client.table = "root"
client.repeating = False
self.handle_key(client, name, raw)
return
client.table = "root"
client.repeating = False
if binding is not None:
self.run_binding(client, binding, pane)
if binding.repeat and client.attached and client.table == "root":
client.table = table
client.repeating = True
client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0
def forward(self, pane, window, raw):
data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw
targets = window.panes() if window.options.get("synchronize-panes") else [pane]
for target in targets:
if not target.dead:
target.write(data)
target.input_bytes += len(data)
def run_binding(self, client, binding, pane=None):
self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command)
def run_checked(self, ctx, text):
try:
output = self.run_line(ctx, text)
if output:
self.show_output(ctx, output)
except MplexError as exc:
self.message(ctx.client, str(exc))
except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down
import traceback
sys.stderr.write(traceback.format_exc())
self.message(ctx.client, "internal error: " + short_error(exc))
def show_output(self, ctx, text):
pane = ctx.pane
lines = text.rstrip("\n").split("\n")
if pane is None or (len(lines) == 1 and len(lines[0]) < 200):
self.message(ctx.client, lines[0] if lines else "")
return
old = pane.find_mode(MplexCopyMode)
if old is not None and old.static_rows is not None:
old.close()
pane.push_mode(MplexCopyMode(self, pane, lines=lines))
self.mark_dirty(pane.window)
def ask_confirm(self, client, text, callback):
def done(value):
callback()
client.prompt = MplexPrompt(text + " ", "", done, confirm=True)
client.dirty = True
def refresh_choosers(self):
for pane in list(self.panes.values()):
for mode in list(pane.modes):
if isinstance(mode, MplexChooser):
mode.rebuild()
self.mark_dirty()
# ---- command execution -----------------------------------------------
def run_line(self, ctx, text, depth=0):
return self.run_commands(ctx, MplexCommandParser.parse(text), depth)
def run_commands(self, ctx, commands, depth=0):
if depth > MPLEX_COMMAND_DEPTH_MAX:
raise MplexError("commands nested too deeply")
flat = []
for words in commands:
piece = []
for word in words:
if word == ";":
if piece:
flat.append(piece)
piece = []
else:
piece.append(word)
if piece:
flat.append(piece)
output = []
for words in flat:
text = self.run_one(ctx, words, depth)
if text:
output.append(text)
return "\n".join(output)
def run_words(self, ctx, words):
return self.run_commands(ctx, [words], 0)
def run_one(self, ctx, words, depth):
name = MplexCommands.resolve(words[0])
aliases, spec, low, high = MPLEX_COMMAND_SPECS[name]
args = MplexArgs.parse(name, spec, words[1:], low, high)
ctx.depth = depth
handler = getattr(self.commands, "cmd_" + name.replace("-", "_"))
return handler(ctx, args) or ""
# ---- sockets ---------------------------------------------------------
def open_listener(self):
lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(lock_fd)
return None
self.lock_fd = lock_fd
try:
os.unlink(self.sock_path)
except OSError:
pass
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(self.sock_path)
os.chmod(self.sock_path, 0o600)
listener.listen(16)
listener.setblocking(False)
return listener
def accept(self):
try:
sock, _addr = self.listener.accept()
except OSError:
return
sock.setblocking(False)
if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2:
sock.close()
return
self.conns.append(MplexPending(sock))
def pending_read(self, pending):
try:
data = pending.sock.recv(65536)
except (BlockingIOError, InterruptedError):
return
except OSError:
data = b""
if not data:
self.drop_pending(pending)
return
pending.buf += data
if pending.buf[:1] == MPLEX_MAGIC:
if len(self.clients) >= MPLEX_MAX_CLIENTS:
self.drop_pending(pending)
return
self.conns.remove(pending)
client = MplexClientConn(self, pending.sock, self.next_id("client"))
self.clients.append(client)
self.client_data(client, bytes(pending.buf[1:]))
return
if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX:
self.control(pending)
def drop_pending(self, pending):
if pending in self.conns:
self.conns.remove(pending)
try:
pending.sock.close()
except OSError:
pass
def control(self, pending):
"""Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController)."""
try:
request = json.loads(bytes(pending.buf).decode("utf-8", "replace"))
if not isinstance(request, dict):
raise ValueError("request must be a JSON object")
reply = self.dispatch_control(request)
except Exception as exc: # noqa: BLE001 -- one bad request must not end the session
reply = {"ok": False, "error": short_error(exc)}
try:
pending.sock.setblocking(True)
pending.sock.settimeout(2)
pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8"))
except OSError:
pass
self.drop_pending(pending)
def control_pane(self, request):
ident = request.get("pane")
if ident not in (None, ""):
pane = self.panes.get(int(str(ident).lstrip("%")))
if pane is None:
raise ValueError("no such pane: %s" % ident)
return pane
session = self.latest_session()
if session is None or session.window() is None:
raise ValueError("no session")
return session.window().active
def dispatch_control(self, request):
cmd = request.get("cmd")
if cmd == "send":
pane = self.control_pane(request)
text = str(request.get("text") or "")
pane.write(text.encode("utf-8", "replace"))
if request.get("enter"):
pane.write(b"\r")
return {"ok": True, "data": True}
if cmd == "read":
pane = self.control_pane(request)
return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))}
if cmd == "status":
pane = self.control_pane(request)
window = pane.window
return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path,
"pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}}
return {"ok": False, "error": "unknown command %r" % cmd}
def client_read(self, client):
try:
data = client.sock.recv(65536)
except (BlockingIOError, InterruptedError):
return
except OSError:
data = b""
if not data:
self.drop_client(client)
return
self.client_data(client, data)
def client_data(self, client, data):
frames = client.frames(data)
if frames is None:
self.drop_client(client)
return
for kind, payload in frames:
if kind == b"H":
self.on_hello(client, payload)
elif kind == b"I" and client.attached:
self.input_bytes(client, payload)
elif kind == b"R":
try:
size = json.loads(payload.decode("utf-8", "replace"))
client.rows = max(2, min(1000, int(size["rows"])))
client.cols = max(2, min(2000, int(size["cols"])))
except (ValueError, KeyError, TypeError):
continue
client.prev = None
if client.attached:
self.resize_session(client.session)
client.dirty = True
def on_hello(self, client, payload):
try:
info = json.loads(payload.decode("utf-8", "replace"))
if not isinstance(info, dict):
raise ValueError("hello must be an object")
client.rows = max(2, min(1000, int(info.get("rows") or 24)))
client.cols = max(2, min(2000, int(info.get("cols") or 80)))
client.term = str(info.get("term") or "")[:64]
client.tty = str(info.get("tty") or "")[:128]
client.cwd = str(info.get("cwd") or "/")[:4096]
client.pid = int(info.get("pid") or 0)
env = info.get("env") or {}
client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {}
pane = info.get("pane")
client.env_pane = int(pane) if pane not in (None, "") else None
commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item]
except (ValueError, TypeError) as exc:
client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8"))
client.closing = True
return
ctx = MplexCtx(self, client=client)
try:
output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text)
except MplexError as exc:
client.send(b"E", str(exc).encode("utf-8"))
client.closing = True
return
except Exception as exc: # noqa: BLE001
import traceback
sys.stderr.write(traceback.format_exc())
client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8"))
client.closing = True
return
if not client.attached:
if output:
client.send(b"M", output.encode("utf-8"))
client.send(b"X", b"")
client.closing = True
def drop_client(self, client):
if client in self.clients:
self.clients.remove(client)
session = client.session
client.attached = False
client.session = None
client.close()
if session is not None and session.id in self.sessions:
self.resize_session(session)
# ---- main loop -------------------------------------------------------
def serve(self):
self.listener = self.open_listener()
if self.listener is None:
return 1
signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False))
signal.signal(signal.SIGHUP, signal.SIG_IGN)
signal.signal(signal.SIGINT, signal.SIG_IGN)
self.running = True
try:
self.loop()
finally:
self.shutdown()
return 0
def stop(self):
self.running = False
def loop(self):
"""Run until stopped. One failing iteration is logged and survived, because it would otherwise
end every pane; MPLEX_LOOP_ERRORS_MAX failures in a row end the server."""
failures = 0
while self.running:
try:
self.iterate()
failures = 0
except Exception: # noqa: BLE001 -- see docstring
import traceback
sys.stderr.write(traceback.format_exc())
sys.stderr.flush()
failures += 1
if failures >= MPLEX_LOOP_ERRORS_MAX:
sys.stderr.write("mplex server: %d failed iterations in a row, exiting\n" % failures)
self.running = False
time.sleep(0.05)
def iterate(self):
now = time.monotonic()
self.run_deadlines(now)
fd_map = {self.listener.fileno(): ("listen", None)}
for pending in self.conns:
fd_map[pending.sock.fileno()] = ("pending", pending)
for client in self.clients:
fd_map[client.sock.fileno()] = ("client", client)
for pane in self.panes.values():
if pane.master_fd is not None:
fd_map[pane.master_fd] = ("pane", pane)
writers = {}
for client in self.clients:
if client.out:
writers[client.sock.fileno()] = client
for pane in self.panes.values():
if pane.pending_in and pane.master_fd is not None:
writers[pane.master_fd] = pane
try:
ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now))
except InterruptedError:
return
except (OSError, ValueError):
self.sweep_bad_fds()
return
for fd in ready:
kind, obj = fd_map[fd]
if kind == "listen":
self.accept()
elif kind == "pending":
self.pending_read(obj)
elif kind == "client":
self.client_read(obj)
elif kind == "pane":
self.pane_read(obj)
for fd in can_write:
target = writers[fd]
if isinstance(target, MplexPane):
target.drain()
else:
target.flush()
now = time.monotonic()
self.reap_children(now)
self.render_clients(now)
self.close_finished()
if not self.sessions and (self.had_session or now - self.started > MPLEX_IDLE_EXIT) and not self.clients_busy():
self.running = False
def clients_busy(self):
return any(client.out for client in self.clients)
def sweep_bad_fds(self):
for pending in list(self.conns):
if pending.sock.fileno() < 0:
self.conns.remove(pending)
for client in list(self.clients):
if client.sock.fileno() < 0:
self.drop_client(client)
for pane in list(self.panes.values()):
if pane.master_fd is not None:
try:
os.fstat(pane.master_fd)
except OSError:
self.pane_exited(pane)
def wakeup(self, now):
due = [now + 1.0]
due.extend(deadline for deadline, _cb in self.timers)
for client in self.clients:
if client.esc_deadline:
due.append(client.esc_deadline)
if client.repeating:
due.append(client.repeat_until)
if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4:
due.append(client.last_render + MPLEX_RENDER_INTERVAL)
if client.overlay is not None and hasattr(client.overlay, "until"):
due.append(client.overlay.until)
return max(0.0, min(due) - now)
def run_deadlines(self, now):
if self.timers:
ripe = [item for item in self.timers if item[0] <= now]
self.timers = [item for item in self.timers if item[0] > now]
for _deadline, callback in ripe:
callback()
for client in list(self.clients):
if client.esc_deadline and now >= client.esc_deadline and client.attached:
client.esc_deadline = None
self.process_keys(client, True)
if client.repeating and now >= client.repeat_until:
client.repeating = False
client.table = "root"
client.dirty = True
overlay = client.overlay
if overlay is not None and hasattr(overlay, "until") and now >= overlay.until:
client.overlay = None
client.dirty = True
if now >= self.next_tick:
self.next_tick = now + 1.0
self.tick(now)
for pending in list(self.conns):
if now - pending.created > 5.0:
self.drop_pending(pending)
def tick(self, now):
for window in list(self.windows.values()):
if window.auto_rename and window.active is not None and window.session is not None:
template = window.options.get("automatic-rename-format")
if window.options.get("automatic-rename"):
resolver = self.resolver_for(window.session, window, window.active)
name = MplexFormat.expand(template, resolver)
if name and name != window.name:
window.name = name[:64]
self.mark_all_dirty()
for pane in window.panes():
if isinstance(pane.top_mode(), MplexClockMode):
self.mark_dirty(window)
interval = self.options.get("status-interval")
if interval and int(now) % interval == 0:
self.mark_all_dirty()
def pane_read(self, pane):
try:
data = os.read(pane.master_fd, 65536)
except (BlockingIOError, InterruptedError):
return
except OSError:
data = b""
if not data:
self.pane_exited(pane)
return
pane.feed(data)
self.mark_dirty(pane.window)
def pane_exited(self, pane):
status = None
if pane.pid is not None:
try:
done, raw = os.waitpid(pane.pid, os.WNOHANG)
if done:
status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw
pane.pid = None
except OSError:
pane.pid = None
if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead:
pane.dead = True
pane.exit_status = status
pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8"))
if pane.master_fd is not None:
try:
os.close(pane.master_fd)
except OSError:
pass
pane.master_fd = None
self.mark_dirty(pane.window)
return
if pane.window is not None and pane.id in self.panes:
self.kill_pane(pane)
def reap_children(self, now):
keep = []
for pid, since in self.reaping:
try:
done, _status = os.waitpid(pid, os.WNOHANG)
except OSError:
continue
if done:
continue
if now - since > MPLEX_REAP_KILL_AFTER:
try:
os.killpg(pid, signal.SIGKILL)
except OSError:
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
keep.append((pid, since))
self.reaping = keep
def render_clients(self, now):
for client in self.clients:
if not client.attached or not client.dirty:
continue
if now - client.last_render < MPLEX_RENDER_INTERVAL:
continue
if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4:
continue
canvas = self.renderer.frame(client)
data, snapshot = canvas.encode(client.prev)
client.last_render = now
client.dirty = False
if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor:
continue
client.prev = snapshot
client.cursor = canvas.cursor
client.send(b"O", data)
def close_finished(self):
for client in list(self.clients):
if client.closing and not client.out:
self.drop_client(client)
def shutdown(self):
for client in list(self.clients):
if client.attached:
client.send(b"X", b"[server exited]")
for _try in range(20):
if not client.out:
break
client.flush()
if client.out:
time.sleep(0.005)
client.close()
self.clients = []
for pane in list(self.panes.values()):
pane.close(hard=True)
if pane.pid is not None:
try:
os.waitpid(pane.pid, os.WNOHANG)
except OSError:
pass
for item in (self.listener,):
try:
item.close()
except (OSError, AttributeError):
pass
try:
os.unlink(self.sock_path)
except OSError:
pass
@staticmethod
def probe(path):
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
probe.settimeout(1)
try:
probe.connect(path)
return True
except OSError:
return False
finally:
probe.close()
@classmethod
def ensure_running(cls, home, wait=5.0):
"""Start the server as a detached daemon unless one already answers on its socket."""
path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME)
if cls.probe(path):
return True
pid = os.fork()
if pid == 0:
code = 1
try:
os.setsid()
if os.fork() > 0:
os._exit(0)
devnull = os.open(os.devnull, os.O_RDWR)
log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
os.dup2(devnull, 0)
os.dup2(log, 1)
os.dup2(log, 2)
os.chdir("/")
code = cls(home).serve()
except BaseException: # noqa: BLE001
import traceback
traceback.print_exc()
os._exit(code)
os.waitpid(pid, 0)
deadline = time.monotonic() + wait
while time.monotonic() < deadline:
if cls.probe(path):
return True
time.sleep(0.05)
return False
class MplexPending:
"""A connection that has not yet said whether it speaks frames or one JSON line."""
def __init__(self, sock):
self.sock = sock
self.buf = bytearray()
self.created = time.monotonic()
# name: (aliases, getopt spec, min positional, max positional)
MPLEX_COMMAND_SPECS = {
"attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0),
"bind-key": (("bind",), "nrT:N:", 1, None),
"break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0),
"capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0),
"choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1),
"choose-client": ((), "NrZF:f:K:O:t:", 0, 1),
"choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1),
"clear-history": (("clearhist",), "Ht:", 0, 0),
"clock-mode": ((), "t:", 0, 0),
"command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1),
"confirm-before": (("confirm",), "byc:p:t:", 1, 1),
"copy-mode": ((), "deHMqSus:t:", 0, 0),
"delete-buffer": (("deleteb",), "b:", 0, 0),
"detach-client": (("detach",), "aPE:s:t:", 0, 0),
"display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1),
"display-panes": (("displayp",), "bNd:t:", 0, 1),
"find-window": (("findw",), "CiNrTZt:", 1, 1),
"has-session": (("has",), "t:", 0, 0),
"if-shell": (("if",), "bFt:", 2, 3),
"kill-pane": (("killp",), "at:", 0, 0),
"kill-server": ((), "", 0, 0),
"kill-session": ((), "aCt:", 0, 0),
"kill-window": (("killw",), "at:", 0, 0),
"last-pane": (("lastp",), "deZt:", 0, 0),
"last-window": (("last",), "t:", 0, 0),
"list-buffers": (("lsb",), "F:f:", 0, 0),
"list-clients": (("lsc",), "F:f:t:", 0, 0),
"list-commands": (("lscm",), "F:", 0, 1),
"list-keys": (("lsk",), "1aNP:T:", 0, 1),
"list-panes": (("lsp",), "asF:f:t:", 0, 0),
"list-sessions": (("ls",), "F:f:", 0, 0),
"list-windows": (("lsw",), "aF:f:t:", 0, 0),
"load-buffer": (("loadb",), "b:t:", 1, 1),
"move-window": (("movew",), "abdkrs:t:", 0, 0),
"new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None),
"new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None),
"next-layout": (("nextl",), "t:", 0, 0),
"next-window": (("next",), "at:", 0, 0),
"paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0),
"previous-layout": (("prevl",), "t:", 0, 0),
"previous-window": (("prev",), "at:", 0, 0),
"refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1),
"rename-session": (("rename",), "t:", 1, 1),
"rename-window": (("renamew",), "t:", 1, 1),
"resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1),
"rotate-window": (("rotatew",), "DUZt:", 0, 0),
"run-shell": (("run",), "bCEc:d:t:", 0, 1),
"save-buffer": (("saveb",), "ab:", 1, 1),
"select-layout": (("selectl",), "Enopt:", 0, 1),
"select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0),
"select-window": (("selectw",), "lnpTt:", 0, 0),
"send-keys": (("send",), "FHKlMRXc:N:t:", 0, None),
"send-prefix": ((), "2t:", 0, 0),
"set-buffer": (("setb",), "awb:n:t:", 0, 1),
"set-option": (("set",), "aFgopqsuUwt:", 1, 2),
"set-window-option": (("setw",), "aFgoqut:", 1, 2),
"show-buffer": (("showb",), "b:", 0, 0),
"show-messages": (("showmsgs",), "JTt:", 0, 0),
"show-options": (("show",), "AgHpqsvwt:", 0, 1),
"show-window-options": (("showw",), "gvt:", 0, 1),
"source-file": (("source",), "Fnqvt:", 1, None),
"split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None),
"start-server": (("start",), "", 0, 0),
"suspend-client": (("suspendc",), "t:", 0, 0),
"swap-pane": (("swapp",), "dDUZs:t:", 0, 0),
"swap-window": (("swapw",), "ds:t:", 0, 0),
"switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0),
"unbind-key": (("unbind",), "anqT:", 0, 1),
}
MPLEX_DEFAULT_FORMATS = {
"list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}",
"list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}",
"list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}",
"list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]",
"list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"",
"display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)",
}
class MplexCommands:
"""tmux's command set as methods named cmd_<name>(ctx, args)."""
def __init__(self, server):
self.s = server
@staticmethod
def resolve(word):
if word in MPLEX_COMMAND_SPECS:
return word
for name, spec in MPLEX_COMMAND_SPECS.items():
if word in spec[0]:
return name
if word in MPLEX_UNSUPPORTED:
raise MplexError("command not supported by mplex: %s" % word)
matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5])))
raise MplexError("unknown command: %s" % word)
# ---- helpers ---------------------------------------------------------
def need_client(self, ctx):
if ctx.client is None or not ctx.client.attached:
raise MplexError("no attached client")
return ctx.client
def pane_target(self, ctx, args, flag="t"):
return MplexTargets.pane(ctx, args.get(flag))
def window_target(self, ctx, args, flag="t"):
return MplexTargets.window(ctx, args.get(flag))
def session_target(self, ctx, args, flag="t"):
return MplexTargets.session(ctx, args.get(flag))
def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None):
base = self.s.resolver_for(session, window, pane, client)
if extra:
def resolve(name):
return extra[name] if name in extra else base(name)
else:
resolve = base
return MplexFormat.with_time(MplexFormat.expand(text, resolve))
def listing(self, command, args, rows):
template = args.get("F") or MPLEX_DEFAULT_FORMATS[command]
flt = args.get("f")
lines = []
for session, window, pane, client, extra in rows:
if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)):
continue
lines.append(self.fmt(template, session, window, pane, client, extra))
return "\n".join(lines)
def substitute(self, template, answers):
out = []
in_quote = False
pos = 0
while pos < len(template):
char = template[pos]
if char == "\\" and pos + 1 < len(template):
out.append(template[pos:pos + 2])
pos += 2
continue
if char == '"':
in_quote = not in_quote
if char == "%" and pos + 1 < len(template):
nxt = template[pos + 1]
index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None)
if index is not None:
value = answers[index] if index < len(answers) else ""
if in_quote:
value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
out.append(value)
pos += 2
continue
out.append(char)
pos += 1
return "".join(out)
def run_later(self, ctx, template, answers):
"""Run template after a prompt finished, against the pane the prompt was opened for."""
pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None
fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True)
fresh.depth = ctx.depth + 1
self.s.run_checked(fresh, self.substitute(template, answers))
# ---- sessions and clients --------------------------------------------
def cmd_new_session(self, ctx, args):
s = self.s
name = args.get("s") or s.auto_session_name()
if ":" in name or "." in name:
raise MplexError("bad session name: %s" % name)
existing = next((item for item in s.sessions.values() if item.name == name), None)
if existing is not None and args.has("A"):
return self.attach(ctx, existing, args.has("D"))
if existing is not None:
raise MplexError("duplicate session: %s" % name)
client = ctx.client
cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd()))
env = dict(client.env) if client is not None and client.env else dict(os.environ)
for item in args.all("e"):
key, _eq, value = item.partition("=")
env[key] = value
size = (80, 24 + (1 if s.options.get("status") else 0))
if args.get("x") or args.get("y"):
size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0))
elif client is not None and not args.has("d"):
size = (client.cols, client.rows)
session = s.create_session(name, cwd, env, args.positional, args.get("n"), size)
if not args.has("d") and client is not None:
s.attach_client(client, session)
if args.has("P"):
return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active)
return ""
def attach(self, ctx, session, detach_others):
client = self.need_client_for_attach(ctx)
if detach_others:
for other in list(self.s.attached_clients(session)):
if other is not client:
self.s.detach_client(other, "[detached (from session %s)]" % session.name)
self.s.attach_client(client, session)
return ""
def need_client_for_attach(self, ctx):
if ctx.client is None:
raise MplexError("no client to attach")
return ctx.client
def cmd_attach_session(self, ctx, args):
if args.get("t"):
session = MplexTargets.session(ctx, args.get("t"))
else:
session = self.s.latest_session()
if session is None:
raise MplexError("no sessions")
return self.attach(ctx, session, args.has("d"))
def cmd_detach_client(self, ctx, args):
s = self.s
if args.get("s"):
session = MplexTargets.session(ctx, args.get("s"))
targets = list(s.attached_clients(session))
else:
current = self.find_client(ctx, args.get("t"))
targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current]
for client in targets:
s.detach_client(client, "[detached (from session %s)]" % client.session.name)
return ""
def find_client(self, ctx, spec):
if not spec:
return self.need_client(ctx)
for client in self.s.attached_clients():
if spec in (client.tty, str(client.id), str(client.pid)):
return client
raise MplexError("can't find client: %s" % spec)
def cmd_has_session(self, ctx, args):
MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None))
return ""
def cmd_kill_session(self, ctx, args):
target = self.session_target(ctx, args)
if args.has("a"):
for session in [item for item in self.s.sessions.values() if item is not target]:
self.s.kill_session(session)
else:
self.s.kill_session(target)
return ""
def cmd_kill_server(self, ctx, args):
self.s.stop()
return ""
def cmd_start_server(self, ctx, args):
return ""
def cmd_list_sessions(self, ctx, args):
rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)]
return self.listing("list-sessions", args, rows)
def cmd_list_windows(self, ctx, args):
sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)]
rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()]
return self.listing("list-windows", args, rows)
def cmd_list_panes(self, ctx, args):
s = self.s
if args.has("a") or args.has("s"):
sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)]
windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()]
else:
sess, _index, window = self.window_target(ctx, args)
windows = [(sess, window)]
rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()]
return self.listing("list-panes", args, rows)
def cmd_list_clients(self, ctx, args):
rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()]
return self.listing("list-clients", args, rows)
def cmd_rename_session(self, ctx, args):
session = self.session_target(ctx, args)
name = args.positional[0]
if not name or ":" in name or "." in name:
raise MplexError("bad session name: %s" % name)
if any(item.name == name and item is not session for item in self.s.sessions.values()):
raise MplexError("duplicate session: %s" % name)
session.name = name
self.s.mark_all_dirty()
return ""
def cmd_switch_client(self, ctx, args):
client = self.need_client(ctx)
s = self.s
if args.has("T"):
client.table = args.get("T")
client.repeating = False
return ""
ordered = sorted(s.sessions.values(), key=lambda x: x.id)
here = ordered.index(client.session)
window = None
if args.has("n"):
target = ordered[(here + 1) % len(ordered)]
elif args.has("p"):
target = ordered[(here - 1) % len(ordered)]
elif args.has("l"):
target = getattr(client, "last_session", None)
if target is None or target.id not in s.sessions:
raise MplexError("no last session")
elif args.get("t"):
spec = args.get("t")
if spec.startswith("%"):
target, window = MplexTargets.pane(ctx, spec)[:2]
elif ":" in spec or spec.startswith("@"):
target, _index, window = MplexTargets.window(ctx, spec)
else:
target = MplexTargets.session(ctx, spec)
else:
raise MplexError("switch-client needs -n, -p, -l, -t or -T")
previous = client.session
s.attach_client(client, target)
if target is not previous:
client.last_session = previous
if window is not None:
target.select(target.index_of(window))
return ""
def cmd_suspend_client(self, ctx, args):
self.need_client(ctx).send(b"Z")
return ""
def cmd_refresh_client(self, ctx, args):
client = self.need_client(ctx)
client.prev = None
client.dirty = True
return ""
def cmd_list_commands(self, ctx, args):
lines = []
for name in sorted(MPLEX_COMMAND_SPECS):
aliases, spec, low, high = MPLEX_COMMAND_SPECS[name]
lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else ""))
return "\n".join(lines)
# ---- windows ---------------------------------------------------------
def cmd_new_window(self, ctx, args):
s = self.s
spec = args.get("t")
session = ctx.session
index = None
anchor = None
if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")):
has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None
if not has_window:
spec = spec + ":"
if spec and spec.endswith(":"):
session = MplexTargets.session(ctx, spec[:-1])
elif spec and self.is_new_index(ctx, spec):
sess_part, _sep, idx_part = spec.rpartition(":")
session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
index = int(idx_part.lstrip("="))
elif spec:
session, index, anchor = MplexTargets.window(ctx, spec)
if session is None:
raise MplexError("no current session")
if index is None:
index = session.free_index(s.options.get("base-index"))
current = session.current
if (args.has("a") or args.has("b")) and current is not None:
index = current + 1 if args.has("a") else current
self.make_room(session, index)
elif anchor is not None:
if args.has("a"):
index += 1
self.make_room(session, index)
elif args.has("b"):
self.make_room(session, index)
elif args.has("k"):
s.kill_window(anchor)
else:
raise MplexError("index in use: %d" % index)
cwd = os.path.expanduser(args.get("c") or session.cwd)
env_extra = dict(item.partition("=")[::2] for item in args.all("e"))
if args.has("S"):
for idx in session.indexes():
if session.windows[idx].name == args.get("n"):
session.select(idx)
return ""
created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d"))
if args.has("P"):
return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active)
return ""
def is_new_index(self, ctx, spec):
"""-t names an index that does not exist yet (the usual new-window -t :N case)."""
if ":" not in spec:
return False
sess_part, _sep, idx_part = spec.rpartition(":")
idx_part = idx_part.lstrip("=")
if not idx_part.isdigit():
return False
try:
session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
except MplexError:
return False
return int(idx_part) not in session.windows
def make_room(self, session, index):
if index not in session.windows:
return
current = session.window()
moved = {}
for idx in session.indexes():
moved[idx + 1 if idx >= index else idx] = session.windows[idx]
session.windows = moved
session.current = session.index_of(current) if current else None
def cmd_kill_window(self, ctx, args):
session, index, window = self.window_target(ctx, args)
if args.has("a"):
for other in [session.windows[i] for i in session.indexes() if i != index]:
self.s.kill_window(other)
else:
self.s.kill_window(window)
return ""
def cmd_select_window(self, ctx, args):
if args.has("n") or args.has("p"):
return self.step_window(ctx, args, 1 if args.has("n") else -1)
if args.has("l"):
return self.cmd_last_window(ctx, args)
session, index, window = self.window_target(ctx, args)
if args.has("T") and index == session.current:
if session.last is None:
raise MplexError("no last window")
session.select(session.last)
self.s.mark_dirty()
return ""
session.select(index)
self.s.mark_dirty()
return ""
def step_window(self, ctx, args, step):
session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0])
if args.has("a"):
raise MplexError("no %s window" % ("next" if step > 0 else "previous"))
indexes = session.indexes()
here = indexes.index(session.current)
session.select(indexes[(here + step) % len(indexes)])
self.s.mark_dirty()
return ""
def cmd_next_window(self, ctx, args):
return self.step_window(ctx, args, 1)
def cmd_previous_window(self, ctx, args):
return self.step_window(ctx, args, -1)
def cmd_last_window(self, ctx, args):
session = MplexTargets.session(ctx, args.get("t"))
if session.last not in session.windows:
raise MplexError("no last window")
session.select(session.last)
self.s.mark_dirty()
return ""
def cmd_rename_window(self, ctx, args):
session, index, window = self.window_target(ctx, args)
if not args.positional[0]:
raise MplexError("empty window name")
window.name = args.positional[0][:64]
window.auto_rename = False
self.s.mark_all_dirty()
return ""
def parse_window_destination(self, ctx, spec):
if ":" in spec:
sess_part, _sep, idx_part = spec.rpartition(":")
session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
else:
session, idx_part = ctx.session, spec
idx_part = idx_part.lstrip("=")
if not idx_part.lstrip("-").isdigit():
raise MplexError("bad window index: %s" % idx_part)
return session, int(idx_part)
def cmd_move_window(self, ctx, args):
s = self.s
if args.has("r"):
s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0]))
s.mark_all_dirty()
return ""
src_session, src_index, window = MplexTargets.window(ctx, args.get("s"))
if not args.get("t"):
raise MplexError("move-window needs -t")
dst_session, idx = self.parse_window_destination(ctx, args.get("t"))
if args.has("a"):
idx += 1
self.make_room(dst_session, idx)
elif args.has("b"):
self.make_room(dst_session, idx)
elif idx in dst_session.windows and dst_session.windows[idx] is not window:
if args.has("k"):
s.kill_window(dst_session.windows[idx])
else:
raise MplexError("index in use: %d" % idx)
if dst_session is src_session and idx == src_index:
return ""
was_current = src_session.current == src_index
src_session.windows.pop(src_index)
src_session.history = [item for item in src_session.history if item is not window]
window.session = dst_session
window.options.parent = dst_session.options
dst_session.windows[idx] = window
if was_current and src_session.windows and src_session is not dst_session:
src_session.current = src_session.indexes()[0]
elif was_current and src_session is dst_session:
src_session.current = idx
if not args.has("d"):
dst_session.select(idx)
if not src_session.windows:
s.kill_session(src_session)
else:
s.resize_session(src_session)
s.resize_session(dst_session)
return ""
def cmd_swap_window(self, ctx, args):
src_session, src_index, src = MplexTargets.window(ctx, args.get("s"))
dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t"))
was = (src_session.window(), dst_session.window())
src_session.windows[src_index], dst_session.windows[dst_index] = dst, src
src.session, dst.session = dst_session, src_session
src.options.parent, dst.options.parent = dst_session.options, src_session.options
for session, current in ((src_session, was[0]), (dst_session, was[1])):
if current is not None:
session.current = session.index_of(current)
self.s.resize_session(src_session)
self.s.resize_session(dst_session)
return ""
def cmd_find_window(self, ctx, args):
s = self.s
text = args.positional[0]
fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "")
fields = fields or "CNT"
if args.has("r"):
flags = re.IGNORECASE if args.has("i") else 0
try:
matcher = re.compile(text, flags).search
except re.error as exc:
raise MplexError("bad regular expression: %s" % exc)
else:
import fnmatch
pattern = "*%s*" % text
low = pattern.lower() if args.has("i") else pattern
def matcher(value):
return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low)
found = []
for session in sorted(s.sessions.values(), key=lambda x: x.id):
for index in session.indexes():
window = session.windows[index]
hit = False
if "N" in fields and matcher(window.name):
hit = True
for pane in window.panes():
if "T" in fields and matcher(pane.title):
hit = True
if "C" in fields and matcher(pane.screen.text()):
hit = True
if hit:
found.append((session, index, window))
if not found:
raise MplexError("no windows matching: %s" % text)
if len(found) == 1:
session, index, _window = found[0]
client = ctx.client
if client is not None and client.attached and client.session is not session:
s.attach_client(client, session)
session.select(index)
s.mark_dirty()
return ""
items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found]
return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args)
# ---- panes -----------------------------------------------------------
def size_arg(self, text, total):
text = text.strip()
if text.endswith("%"):
return max(1, total * int(text[:-1]) // 100)
return int(text)
def cmd_split_window(self, ctx, args):
s = self.s
session, window, target = self.pane_target(ctx, args)
kind = "lr" if args.has("h") else "tb"
leaf = window.leaf_of(target)
size = None
if args.get("l"):
try:
size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h)
except ValueError:
raise MplexError("bad size: %s" % args.get("l"))
cwd = os.path.expanduser(args.get("c") or session.cwd)
env_extra = dict(item.partition("=")[::2] for item in args.all("e"))
pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d"))
if args.has("Z") and not args.has("d"):
window.zoomed = pane
window.relayout()
if args.has("P"):
return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane)
return ""
def cmd_kill_pane(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
if args.has("a"):
for other in [item for item in window.panes() if item is not pane]:
self.s.kill_pane(other)
else:
self.s.kill_pane(pane)
return ""
def cmd_select_pane(self, ctx, args):
s = self.s
if args.has("e") or args.has("d"):
raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex")
if args.has("M"):
s.marked = None
s.mark_all_dirty()
return ""
session, window, pane = self.pane_target(ctx, args)
if args.has("m"):
s.marked = None if s.marked is pane else pane
s.mark_all_dirty()
return ""
if args.get("T") is not None:
pane.screen.title = args.get("T")[:256]
s.mark_all_dirty()
return ""
new = pane
for flag in "UDLR":
if args.has(flag):
found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height)
if found is None:
raise MplexError("no pane in that direction")
new = found
if args.has("l"):
if window.last_pane not in window.panes():
raise MplexError("no last pane")
new = window.last_pane
keep_zoom = args.has("Z") and window.zoomed is not None
zoomed = window.zoomed
s.select_pane(window, new)
if keep_zoom and zoomed is not None:
window.zoomed = new
window.relayout()
if session.window() is not window:
session.select(session.index_of(window))
return ""
def cmd_last_pane(self, ctx, args):
_session, _index, window = MplexTargets.window(ctx, args.get("t"))
if window.last_pane not in window.panes():
raise MplexError("no last pane")
self.s.select_pane(window, window.last_pane)
return ""
def cmd_resize_pane(self, ctx, args):
if args.has("M") or args.has("T"):
raise MplexError("resize-pane -M/-T is not supported by mplex")
session, window, pane = self.pane_target(ctx, args)
if args.has("Z"):
if window.zoomed is not None:
window.zoomed = None
elif len(window.panes()) > 1:
window.zoomed = pane
window.relayout()
self.s.mark_dirty(window)
return ""
leaf = window.leaf_of(pane)
amount = 1
if args.positional:
try:
amount = int(args.positional[0])
except ValueError:
raise MplexError("bad adjustment: %s" % args.positional[0])
try:
if args.get("x"):
MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width))
if args.get("y"):
MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height))
if args.has("R"):
MplexLayout.resize_by(leaf, "lr", amount, True)
elif args.has("L"):
MplexLayout.resize_by(leaf, "lr", -amount, False)
elif args.has("D"):
MplexLayout.resize_by(leaf, "tb", amount, True)
elif args.has("U"):
MplexLayout.resize_by(leaf, "tb", -amount, False)
except MplexError:
pass
window.layout_index = -1
window.relayout()
self.s.mark_dirty(window)
return ""
def cmd_swap_pane(self, ctx, args):
s = self.s
if args.has("U") or args.has("D"):
_s, window, dst = self.pane_target(ctx, args)
panes = window.panes()
here = panes.index(dst)
src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)]
else:
_s, _w, dst = self.pane_target(ctx, args)
if args.get("s"):
_s2, _w2, src = MplexTargets.pane(ctx, args.get("s"))
elif s.marked is not None:
src = s.marked
else:
raise MplexError("no marked pane")
if src is dst:
return ""
wa, wb = src.window, dst.window
leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst)
leaf_a.pane, leaf_b.pane = dst, src
src.window, dst.window = wb, wa
if wa is not wb:
if wa.active is src:
wa.active = dst
if wb.active is dst:
wb.active = src
for window in (wa,) if wa is wb else (wa, wb):
window.relayout()
s.marked = None
s.mark_all_dirty()
return ""
def cmd_rotate_window(self, ctx, args):
session, index, window = self.window_target(ctx, args)
panes = window.panes()
if len(panes) < 2:
return ""
order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1]
position = panes.index(window.active)
for leaf, pane in zip(window.root.leaves(), order):
leaf.pane = pane
window.active = order[position]
window.relayout()
s = self.s
s.mark_dirty(window)
return ""
def cmd_break_pane(self, ctx, args):
s = self.s
session, window, pane = MplexTargets.pane(ctx, args.get("s"))
if len(window.panes()) < 2:
raise MplexError("can't break with only one pane")
dst_session = session
index = dst_session.free_index(s.options.get("base-index"))
if args.get("t"):
dst_session, index = self.parse_window_destination(ctx, args.get("t"))
if index in dst_session.windows:
raise MplexError("index in use: %d" % index)
leaf = window.leaf_of(pane)
was_active = window.active is pane
window.root = MplexLayout.remove(window.root, leaf)
if window.zoomed is pane:
window.zoomed = None
rest = window.panes()
if was_active or window.active is pane:
window.active = window.last_pane if window.last_pane in rest else rest[0]
if window.last_pane is pane:
window.last_pane = None
window.relayout()
cols, rows = getattr(dst_session, "size", (80, 24))
fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options)
fresh.auto_rename = not args.get("n")
fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height)
fresh.active = pane
pane.window = fresh
s.register_window(dst_session, fresh, index, not args.has("d"))
fresh.relayout()
s.mark_all_dirty()
if args.has("P"):
return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane)
return ""
def apply_layout(self, window, name, index):
panes = window.panes()
opts = window.options
window.zoomed = None
window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height"))
window.layout_index = index
window.relayout()
self.s.mark_dirty(window)
def cmd_select_layout(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
names = MPLEX_LAYOUT_NAMES
if args.has("E"):
leaf = window.leaf_of(pane)
parent = leaf.parent
if parent is not None:
for child in parent.children:
MplexLayout.set_size(child, parent.kind, 1)
window.relayout()
self.s.mark_dirty(window)
return ""
if args.positional:
wanted = args.positional[0]
matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)]
if len(matches) != 1:
raise MplexError("unknown or ambiguous layout: %s" % wanted)
self.apply_layout(window, matches[0], names.index(matches[0]))
return ""
if args.has("p"):
index = (window.layout_index - 1) % len(names)
elif args.has("o"):
index = window.layout_index if window.layout_index >= 0 else 0
else:
index = (window.layout_index + 1) % len(names)
self.apply_layout(window, names[index], index)
return ""
def cmd_next_layout(self, ctx, args):
session, index, window = self.window_target(ctx, args)
self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES))
return ""
def cmd_previous_layout(self, ctx, args):
session, index, window = self.window_target(ctx, args)
step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES)
self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step)
return ""
def cmd_clear_history(self, ctx, args):
_s, _w, pane = self.pane_target(ctx, args)
pane.clear_history()
return ""
def cmd_capture_pane(self, ctx, args):
_s, _w, pane = self.pane_target(ctx, args)
screen = pane.screen
if args.has("a") and not screen.alt_active:
if args.has("q"):
return ""
raise MplexError("no alternate screen")
history = list(screen.history or [])
rows = history + [list(row) for row in screen.grid]
base = len(history)
def line_of(row):
text = "".join(cell[0] if cell else " " for cell in row)
return text if args.has("N") else text.rstrip()
def bound(value, default):
if value is None:
return default
if value == "-":
return None
try:
return base + int(value)
except ValueError:
raise MplexError("bad line number: %s" % value)
start = bound(args.get("S"), base)
end = bound(args.get("E"), base + pane.h - 1)
start = 0 if start is None else max(0, start)
end = len(rows) - 1 if end is None else min(len(rows) - 1, end)
text = "\n".join(line_of(rows[i]) for i in range(start, end + 1))
if args.has("p"):
return text
self.s.add_buffer(text + "\n", args.get("b"))
return ""
# ---- keys, prompts, input --------------------------------------------
def cmd_send_prefix(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
name = session.options.get("prefix2" if args.has("2") else "prefix")
if name == "None":
raise MplexError("no prefix key set")
self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor))
return ""
def cmd_send_keys(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
words = list(args.positional)
count = args.int("N", None)
if args.has("M"):
raise MplexError("send-keys -M (mouse) is not supported by mplex")
if args.has("X"):
mode = pane.top_mode()
if not isinstance(mode, MplexCopyMode):
raise MplexError("pane is not in a mode")
if not words:
raise MplexError("send-keys -X needs a command")
if len(words) > 1 and words[1] == "--":
del words[1]
if args.has("F"):
words = [self.fmt(item, session, window, pane, ctx.client) for item in words]
if count:
pane.prefix_count = count
mode.command(words[0], words[1:], ctx.client)
return ""
if args.has("R"):
pane.screen.reset()
if not words:
if count:
pane.prefix_count = count
return ""
if args.has("F"):
words = [self.fmt(item, session, window, pane, ctx.client) for item in words]
data = b""
if args.has("l"):
data = "".join(words).encode("utf-8")
elif args.has("H"):
try:
data = bytes(int(item, 16) for item in words)
except ValueError:
raise MplexError("bad hex byte in send-keys -H")
else:
for word in words:
try:
data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor)
except MplexError:
data += word.encode("utf-8")
mode = pane.top_mode()
for _i in range(max(1, min(count or 1, 1000))):
if mode is not None and not args.has("l") and not args.has("H"):
tokens, _rest = MplexKeys.split(data, True)
for name, raw in tokens:
mode.key(name, raw, ctx.client)
elif not pane.dead:
pane.write(data)
self.s.mark_dirty(window)
return ""
def cmd_bind_key(self, ctx, args):
table = "root" if args.has("n") else (args.get("T") or "prefix")
key = MplexKeys.canonical(args.positional[0])
words = args.positional[1:]
if not words:
raise MplexError("bind-key needs a command")
command = " ".join(MplexCommandParser.quote(word) for word in words)
for parsed in MplexCommandParser.parse(command):
self.resolve(parsed[0])
self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "")
return ""
def cmd_unbind_key(self, ctx, args):
table = "root" if args.has("n") else (args.get("T") or "prefix")
config = self.s.config
if args.has("a"):
config.tables[table] = {}
return ""
if not args.positional:
raise MplexError("unbind-key needs a key (or -a)")
key = MplexKeys.canonical(args.positional[0])
if not config.unbind(table, key) and not args.has("q"):
raise MplexError("unknown key: %s" % args.positional[0])
return ""
def cmd_list_keys(self, ctx, args):
config = self.s.config
opts = ctx.session.options if ctx.session else self.s.options
only = MplexKeys.canonical(args.positional[0]) if args.positional else None
tables = [args.get("T")] if args.get("T") else list(config.tables)
lines = []
if args.has("N"):
prefix = opts.get("prefix")
rows = []
for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]):
for key, binding in config.tables.get(table, {}).items():
if only is not None and key != only:
continue
note = binding.note or (binding.command if args.has("a") else "")
if not note:
continue
label = key if table == "root" else "%s %s" % (prefix, key)
if args.get("P") is not None:
label = args.get("P") + key
rows.append((label, note))
if only is not None and not rows:
raise MplexError("unknown key: %s" % args.positional[0])
if args.has("1"):
rows = rows[:1]
width = max([len(label) for label, _note in rows] or [0])
return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows)
for table in tables:
for key, binding in config.tables.get(table, {}).items():
if only is not None and key != only:
continue
lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command))
if args.has("1") and lines:
lines = lines[:1]
return "\n".join(lines)
def cmd_command_prompt(self, ctx, args):
s = self.s
client = self.need_client(ctx)
template = args.positional[0] if args.positional else None
prompts = args.get("p").split(",") if args.get("p") else []
inputs = args.get("I").split(",") if args.get("I") else []
prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts]
inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs]
if template is not None and args.has("F"):
template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client)
kind = args.get("T") or "command"
history = s.prompt_history.setdefault(kind, [])
answers = []
count = max(1, len(prompts))
pane = ctx.pane
copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None
def ask(position):
label = (prompts[position] + " ") if prompts else ":"
initial = inputs[position] if position < len(inputs) else ""
def done(value):
answers.append(value)
if position + 1 < count:
ask(position + 1)
return
if template is None:
self.run_later(ctx, "%%", answers)
else:
self.run_later(ctx, template, answers)
kwargs = {}
if args.has("i") and template is not None:
kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text])
kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text])
if copy_mode is not None:
kwargs["on_cancel"] = copy_mode.search_cancel
client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs)
client.dirty = True
ask(0)
return ""
def cmd_confirm_before(self, ctx, args):
client = self.need_client(ctx)
command = args.positional[0]
text = args.get("p") or "Confirm '%s'? (y/n)" % command
text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client)
pane = ctx.pane
def run():
fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True)
fresh.depth = ctx.depth + 1
self.s.run_checked(fresh, command)
self.s.ask_confirm(client, text, run)
return ""
def cmd_display_message(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"]
text = self.fmt(template, session, window, pane, ctx.client)
text = MplexFormat.strip_styles(text)
if args.has("p"):
return text
client = ctx.client
if client is not None and client.attached:
delay = args.int("d", None)
self.s.message(client, text, delay)
return ""
def cmd_display_panes(self, ctx, args):
client = self.need_client(ctx)
window = client.session.window()
duration = args.int("d", None) or window.options.get("display-panes-time")
client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0)
client.dirty = True
return ""
def cmd_clock_mode(self, ctx, args):
_s, window, pane = self.pane_target(ctx, args)
if pane.find_mode(MplexClockMode) is None:
pane.push_mode(MplexClockMode(self.s, pane))
self.s.mark_dirty(window)
return ""
def cmd_copy_mode(self, ctx, args):
if args.has("M"):
raise MplexError("copy-mode -M (mouse) is not supported by mplex")
_s, window, pane = self.pane_target(ctx, args)
existing = pane.find_mode(MplexCopyMode)
if args.has("q"):
if existing is not None:
existing.close()
return ""
if existing is None:
existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H"))
pane.push_mode(existing)
if args.has("u"):
existing.command("page-up", [], ctx.client)
self.s.mark_dirty(window)
return ""
def cmd_show_messages(self, ctx, args):
return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages)
# ---- buffers ---------------------------------------------------------
def buffer_rows(self):
rows = []
for item in self.s.buffers:
sample = item["data"][:50].replace("\n", " ")
rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample}))
return rows
def cmd_list_buffers(self, ctx, args):
return self.listing("list-buffers", args, self.buffer_rows())
def cmd_set_buffer(self, ctx, args):
name = args.get("b")
if args.get("n"):
item = self.s.find_buffer(name)
item["name"] = args.get("n")
return ""
if not args.positional:
raise MplexError("set-buffer needs data")
data = args.positional[0]
if args.has("a"):
item = self.s.find_buffer(name)
item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX]
else:
self.s.add_buffer(data, name)
return ""
def cmd_show_buffer(self, ctx, args):
return self.s.find_buffer(args.get("b"))["data"]
def cmd_delete_buffer(self, ctx, args):
item = self.s.find_buffer(args.get("b"))
self.s.buffers.remove(item)
self.s.refresh_choosers()
return ""
def cmd_save_buffer(self, ctx, args):
item = self.s.find_buffer(args.get("b"))
path = os.path.expanduser(args.positional[0])
try:
with open(path, "ab" if args.has("a") else "wb") as handle:
handle.write(item["data"].encode("utf-8"))
except OSError as exc:
raise MplexError("cannot write %s: %s" % (path, short_error(exc)))
return ""
def cmd_load_buffer(self, ctx, args):
path = os.path.expanduser(args.positional[0])
try:
with open(path, "rb") as handle:
data = handle.read(MPLEX_PASTE_MAX)
except OSError as exc:
raise MplexError("cannot read %s: %s" % (path, short_error(exc)))
self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b"))
return ""
def cmd_paste_buffer(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
item = self.s.find_buffer(args.get("b"))
separator = args.get("s") if args.get("s") is not None else "\r"
text = item["data"] if args.has("r") else item["data"].replace("\n", separator)
data = text.encode("utf-8")
if args.has("p") and pane.screen.bracketed_paste:
data = b"\x1b[200~" + data + b"\x1b[201~"
if not pane.dead:
pane.write(data)
if args.has("d"):
self.s.buffers.remove(item)
return ""
# ---- choosers --------------------------------------------------------
def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None):
s = self.s
pane = ctx.pane
if pane is None:
raise MplexError("no pane")
window = pane.window
zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1
if zoom_on_open:
window.zoomed = pane
window.relayout()
def restore():
if zoom_on_open and window.zoomed is pane:
window.zoomed = None
window.relayout()
def choose(node, client):
kind_tag, ident = node.payload
if kind_tag == "window":
target = s.windows.get(ident)
if target is None:
return
if client is not None and client.session is not target.session:
s.attach_client(client, target.session)
target.session.select(target.session.index_of(target))
elif kind_tag == "pane":
target = s.panes.get(ident)
if target is None or target.window is None:
return
if client is not None and client.session is not target.window.session:
s.attach_client(client, target.window.session)
target.window.session.select(target.window.session.index_of(target.window))
s.select_pane(target.window, target)
elif kind_tag == "session":
target = s.sessions.get(ident)
if target is not None and client is not None:
s.attach_client(client, target)
s.mark_all_dirty()
mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore)
pane.push_mode(mode)
s.mark_dirty(window)
return ""
def cmd_choose_tree(self, ctx, args):
s = self.s
sessions_only = args.has("s")
windows_collapsed = args.has("w")
def builder():
items = []
for session in sorted(s.sessions.values(), key=lambda x: x.id):
kids = []
for index in session.indexes():
window = session.windows[index]
panes = []
for number, pane in enumerate(window.panes()):
panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id)))
kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height),
("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1))
attached = " (attached)" if s.attached_clients(session) else ""
items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only))
return items
def preview(node, width, height):
kind_tag, ident = node.payload
pane = None
if kind_tag == "pane":
pane = s.panes.get(ident)
elif kind_tag == "window":
window = s.windows.get(ident)
pane = window.active if window else None
else:
session = s.sessions.get(ident)
pane = session.window().active if session else None
if pane is None:
return []
blank = MplexCanvas.BLANK
return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]]
def delete(node, client):
kind_tag, ident = node.payload
if kind_tag == "session" and ident in s.sessions:
s.kill_session(s.sessions[ident])
elif kind_tag == "window" and ident in s.windows:
s.kill_window(s.windows[ident])
elif kind_tag == "pane" and ident in s.panes:
s.kill_pane(s.panes[ident])
selected = ("window", ctx.window.id) if ctx.window is not None else None
return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected)
def cmd_choose_buffer(self, ctx, args):
s = self.s
pane = ctx.pane
def builder():
return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers]
def choose(node, client):
if pane is not None and pane.id in s.panes and not pane.dead:
item = s.find_buffer(node.payload[1])
pane.write(item["data"].replace("\n", "\r").encode("utf-8"))
def delete(node, client):
s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]]
def preview(node, width, height):
data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "")
return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]]
if not s.buffers:
raise MplexError("no buffers")
return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview)
def cmd_choose_client(self, ctx, args):
s = self.s
def builder():
return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()]
def choose(node, client):
for target in s.attached_clients():
if target.id == node.payload[1]:
s.detach_client(target, "[detached (from session %s)]" % target.session.name)
return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose)
# ---- options, shell, files -------------------------------------------
def option_name(self, name):
if name in MplexOptions.DEFS:
return name
matches = [item for item in MplexOptions.DEFS if item.startswith(name)]
if len(matches) == 1:
return matches[0]
raise MplexError("unknown or unsupported option: %s" % name)
def option_scope(self, ctx, args, window_scope):
if args.has("g") or args.has("s"):
return self.s.options
if window_scope or args.has("w"):
return MplexTargets.window(ctx, args.get("t"))[2].options
return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options
def set_option(self, ctx, args, window_scope):
try:
name = self.option_name(args.positional[0])
except MplexError:
if args.has("q"):
return ""
raise
scope = self.option_scope(ctx, args, window_scope)
if args.has("u"):
scope.unset(name)
else:
if len(args.positional) < 2:
scope.toggle(name)
else:
value = args.positional[1]
if args.has("F"):
value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client)
if args.has("o") and scope.is_set(name):
raise MplexError("already set: %s" % name)
scope.set(name, value, args.has("a"))
self.s.resize_all()
self.s.mark_all_dirty()
return ""
def cmd_set_option(self, ctx, args):
return self.set_option(ctx, args, False)
def cmd_set_window_option(self, ctx, args):
return self.set_option(ctx, args, True)
def show_options(self, ctx, args, window_scope):
scope = self.option_scope(ctx, args, window_scope)
names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS)
lines = []
for name in names:
if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name):
value = scope.get(name)
text = MplexOptions.show(value)
if args.has("v"):
lines.append(text)
else:
lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text))
return "\n".join(lines)
def cmd_show_options(self, ctx, args):
return self.show_options(ctx, args, False)
def cmd_show_window_options(self, ctx, args):
return self.show_options(ctx, args, True)
def shell_in(self, ctx, command, pane):
cwd = pane.current_path() if pane is not None else None
env = dict(ctx.session.env) if ctx.session is not None else None
try:
done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env)
except (OSError, subprocess.SubprocessError) as exc:
raise MplexError("shell command failed: " + short_error(exc))
return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace")
def cmd_if_shell(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
condition = args.positional[0]
if args.has("F"):
ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client))
else:
ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0
chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "")
if not chosen:
return ""
return self.s.run_line(ctx, chosen, ctx.depth + 1)
def cmd_run_shell(self, ctx, args):
session, window, pane = self.pane_target(ctx, args)
if args.get("d"):
raise MplexError("run-shell -d (delay) is not supported by mplex")
if not args.positional:
return ""
command = self.fmt(args.positional[0], session, window, pane, ctx.client)
if args.has("C"):
return self.s.run_line(ctx, command, ctx.depth + 1)
if args.has("b"):
try:
subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
cwd=pane.current_path() or None, start_new_session=True)
except OSError as exc:
raise MplexError("shell command failed: " + short_error(exc))
return ""
return self.shell_in(ctx, command, pane)[1].rstrip("\n")
def cmd_source_file(self, ctx, args):
s = self.s
out = []
for raw in args.positional:
path = os.path.expanduser(raw)
if not os.path.exists(path):
if args.has("q"):
continue
raise MplexError("no such file: %s" % raw)
if path.endswith(".toml"):
before = s.config.skipped
s.config.apply_file(path)
for problem in s.config.errors[before:]:
s.log_message("mplex.toml: " + problem)
skipped = s.config.skipped - before
if skipped:
out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped))
s.resize_all()
s.mark_all_dirty()
continue
try:
with open(path, "r", encoding="utf-8") as handle:
lines = handle.read(MPLEX_CONFIG_MAX).splitlines()
except OSError as exc:
raise MplexError("cannot read %s: %s" % (path, short_error(exc)))
for number, line in enumerate(lines, 1):
if not line.strip() or line.lstrip().startswith("#"):
continue
try:
text = s.run_line(ctx, line, ctx.depth + 1)
except MplexError as exc:
raise MplexError("%s:%d: %s" % (raw, number, exc))
if text:
out.append(text)
return "\n".join(out)
class MplexClient:
"""The attached terminal: forwards keys and resizes to the server, paints what it sends."""
def __init__(self, home, commands):
self.home = home
self.commands = commands
self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME)
self.stdin_fd = None
self.saved = None
self.attached = False
self.wake_r = None
self.wake_w = None
@staticmethod
def frame(kind, payload=b""):
return kind + struct.pack(">I", len(payload)) + payload
def size(self):
try:
packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8)
rows, cols = struct.unpack("HH", packed[:4])
if rows and cols:
return rows, cols
except (OSError, ValueError, AttributeError):
pass
return 24, 80
def hello(self):
rows, cols = self.size()
pane = os.environ.get(PLEXER_PANE_ENV, "")
info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(),
"tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands,
"env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""}
return self.frame(b"H", json.dumps(info).encode("utf-8"))
def enter(self):
self.stdin_fd = sys.stdin.fileno()
try:
self.saved = termios.tcgetattr(self.stdin_fd)
tty.setraw(self.stdin_fd)
except (termios.error, OSError):
self.saved = None
os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H")
self.attached = True
def leave(self):
if not self.attached:
return
self.attached = False
try:
os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l")
except OSError:
pass
if self.saved is not None:
try:
termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved)
except (termios.error, OSError):
pass
def write_out(self, data):
fd = sys.stdout.fileno()
view = memoryview(data)
while view:
try:
written = os.write(fd, view)
except BlockingIOError:
select.select([], [fd], [], 1.0)
continue
view = view[written:]
def on_winch(self, _signum, _frame):
try:
os.write(self.wake_w, b"w")
except OSError:
pass
def run(self):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(self.sock_path)
except OSError:
sys.stderr.write("no server running on %s\n" % self.sock_path)
return 1
sock.sendall(MPLEX_MAGIC + self.hello())
self.wake_r, self.wake_w = os.pipe()
os.set_blocking(self.wake_w, False)
previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None
buf = bytearray()
code = 0
message = ""
try:
while True:
watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else [])
try:
ready, _w, _x = select.select(watch, [], [])
except InterruptedError:
continue
if self.wake_r in ready:
os.read(self.wake_r, 4096)
rows, cols = self.size()
sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii")))
if self.attached and self.stdin_fd in ready:
data = os.read(self.stdin_fd, 4096)
if not data:
message = "[detached]"
break
sock.sendall(self.frame(b"I", data))
if sock in ready:
data = sock.recv(262144)
if not data:
code = 1
message = "[lost server]"
break
buf += data
done = self.handle_frames(sock, buf)
if done is not None:
code, message = done
break
finally:
self.leave()
if previous is not None:
signal.signal(signal.SIGWINCH, previous)
sock.close()
os.close(self.wake_r)
os.close(self.wake_w)
if message:
(sys.stderr if code else sys.stdout).write(message + "\n")
return code
def handle_frames(self, sock, buf):
while len(buf) >= 5:
length = struct.unpack(">I", bytes(buf[1:5]))[0]
if len(buf) < 5 + length:
return None
kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length])
del buf[:5 + length]
if kind == b"A":
self.enter()
elif kind == b"O":
self.write_out(payload)
elif kind == b"M":
sys.stdout.write(payload.decode("utf-8", "replace") + "\n")
sys.stdout.flush()
elif kind == b"E":
return 1, payload.decode("utf-8", "replace")
elif kind in (b"D", b"X"):
return 0, payload.decode("utf-8", "replace")
elif kind == b"Z":
self.suspend(sock)
return None
def suspend(self, sock):
self.leave()
os.kill(os.getpid(), signal.SIGTSTP)
self.enter()
rows, cols = self.size()
sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii")))
class MplexCli:
"""tai.py --mplex <command...>: any mplex command; new-session and attach-session attach this terminal."""
@staticmethod
def attaches(name, rest):
if name == "attach-session":
return True
if name != "new-session":
return False
try:
return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d")
except MplexError:
return True
@staticmethod
def split_commands(words):
"""argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument."""
commands = [[]]
for word in words:
if word == ";":
commands.append([])
else:
commands[-1].append(";" if word == "\\;" else word)
return [item for item in commands if item]
@classmethod
def main(cls, home, words):
commands = cls.split_commands(words) or [["new-session"]]
try:
name = MplexCommands.resolve(commands[0][0])
except MplexError as exc:
sys.stderr.write("%s\n" % exc)
return 1
commands[0][0] = name
words = commands[0]
if cls.attaches(name, words[1:]):
if os.environ.get(PLEXER_ACTIVE_ENV):
sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV)
return 1
if not (sys.stdin.isatty() and sys.stdout.isatty()):
sys.stderr.write("open terminal failed: not a terminal\n")
return 1
if name in ("new-session", "start-server"):
if not MplexServer.ensure_running(home):
sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME))
return 1
return MplexClient(home, commands).run()
class NativePlexerBackend(PlexerBackend):
"""What the shell hook execs into: a new mplex session attached to this terminal."""
def __init__(self, home, profile, shell_cmd=None):
self.home = home
self.profile = profile
self.shell_cmd = shell_cmd
def run(self):
if pty is None or termios is None or select is None or fcntl is None:
return "error: pty/termios/select not available on this platform"
return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else []))