#!/usr/bin/env python3 # retoor 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 .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 ".""" 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_(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 : 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 []))