From 46682d42c378e53a3b6301b7ac911267e0c82805 Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 12 Oct 2026 17:47:14 +0200 Subject: [PATCH] mplex: copy mode matches tmux 3.6 (cursor, scrolling, search, selections), window selection history, loop survives a failing iteration, spec mplex.md Copy mode was compared with the installed tmux 3.6 by differential fuzzing and 842 recorded results instead of being written from memory: the cursor stands between characters and remembers its column (lastcx/lastsx), pages are rows - 2, searches land at the match end for emacs keys and the match start for vi keys and scroll by tmux's rule, a failed search is a status message, emacs selections are half open and vi selections include the cursor character and the line end, select-word on blanks takes the previous word, copy-line and copy-end-of-line start a fresh selection. Window selection keeps a history (last, then previous with wrap when the current window dies), swap-window keeps the current window current, new-session -d -x -y sizes the window as given. Server loop logs and survives a failing iteration (50 in a row end it). mplex.md is the specification (architecture, wire format, caps, every option, command and default key, what was checked against tmux, known differences); MplexSpecTests fails when it drifts from the code. New: MplexCopyModeVersusTmuxTests (tmux-recorded fixtures), MplexSessionTests, MplexServerLoopTests. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- mplex.md | 681 +++++++++++++++++++++++++++++++++++++++++ tai.py | 146 +++++---- test_tai.py | 853 +++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1618 insertions(+), 64 deletions(-) create mode 100644 mplex.md diff --git a/README.md b/README.md index a4f1f7d..2950697 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ base-index = 1 "C-b" = false ``` -A bad entry is skipped, counted, announced on the status line and listed by `C-b ~`; it never stops the multiplexer from starting. Inside tmux, both use `C-b`, so set a different prefix. The full specification (architecture, wire format, hard caps, option and command tables, every default key, known gaps) is [mplex.md](mplex.md); the default prefix in earlier versions of the wrapper was `Ctrl+A` with `Ctrl+A q` killing the shell, which is gone: use `C-b d` to detach and `exit` to end a shell. +A bad entry is skipped, counted, announced on the status line and listed by `C-b ~`; it never stops the multiplexer from starting. Inside tmux, both use `C-b`, so set a different prefix. The full specification (architecture, wire format, hard caps, option and command tables, every default key, known gaps) is [mplex.md](mplex.md); the default prefix in earlier versions of the wrapper was `Ctrl+A` with `Ctrl+A q` killing the shell, which is gone: use `C-b d` to detach and `exit` to end a shell. Because the hook replaces the login shell, detaching also closes that terminal window; the session keeps running and `tai.py --mplex attach` from any terminal brings it back. The auto-wrap checks three conditions, in the shell hook itself, before ever invoking `tai.py --plexer-wrap`: never wrap if a plexer is already active (`$TAI_PLEXER_ACTIVE`), never wrap if tmux already is (`$TMUX`), and never wrap if `~/.tai-no-plexer` exists — the hand brake. That file is the one thing to touch if the auto-wrap is ever doing the wrong thing on a given machine; it needs no flag or argument, just existing is enough, and it's rechecked on the Python side too (`plexer_should_wrap()`) as defense in depth, not trusted to the shell guard alone. diff --git a/mplex.md b/mplex.md new file mode 100644 index 0000000..4e9b929 --- /dev/null +++ b/mplex.md @@ -0,0 +1,681 @@ +# mplex: tai's terminal multiplexer + +Specification of what the multiplexer does today. The code is the `Mplex*` classes in `tai.py`; the +default key tables, command specs and option definitions in this file are checked against that code +by `MplexSpecTests` in `test_tai.py`, so this page cannot silently drift. Gaps are listed in +[Differences from tmux](#differences-from-tmux-and-gaps), next to the code that would close them. + +## What it is + +A tmux-shaped multiplexer inside the single `tai.py` file, stdlib only. Key bindings, command names, +command flags and option names follow tmux 3.6; the default tables were dumped from the real binary +(research: `research/shell-multiplexer-auto-attach/local-tmux-3-6-keybinding-and-option-dumps.md`). +The default prefix is `C-b`, as in tmux. Everything is rebindable in `~/.tai/mplex.toml`. + +``` +tai.py --mplex new session, attach this terminal (what the shell hook runs) +tai.py --mplex attach attach to the most recently used session +tai.py --mplex new -s work -d a detached session called work +tai.py --mplex ls list sessions +tai.py --mplex run a command against the running server (split-window -h, list-keys, ...) +tai.py --plexer-wrap the same as bare --mplex; this is what the installed shell hooks exec +``` + +Commands follow tmux's grammar: `;` as its own argument separates commands (`\;` is a literal +semicolon argument), targets use `-t session:window.pane`, `%pane`, `@window`, `$session`, `+`, `-`, `!`. +Commands print their output and exit 0, or print the error and exit 1. + +Inside a pane `TAI_PLEXER_ACTIVE=1`, `TAI_PLEXER_SOCKET=` and `TAI_PLEXER_PANE=` are set, +so the agent tools (`plexer_send`, `plexer_read`, `plexer_type`, `plexer_status`) reach the pane they run in. +Attaching from inside a pane is refused ("sessions should be nested with care"), as in tmux. +If you run mplex inside tmux, both use `C-b`: set `prefix = "C-a"` in `mplex.toml`, or send `C-b C-b`. + +## Architecture + +- **Server**: one detached daemon per `TAI_HOME` (`~/.tai` by default), started by the first + `new-session`/`start-server`. A single `select` loop, no threads. It owns the ptys, a `TuiVtScreen` per + pane with scrollback, the layouts, sessions, paste buffers and the key state of every client. It + exits when its last session ends (or, if it never had one, after `MPLEX_IDLE_EXIT` seconds). Its stdout and + stderr go to `plexer/server.log`; tracebacks land there. +- **Client**: `tai.py --mplex ...` connects to `plexer/server.sock`, sends one hello (size, `TERM`, cwd, + environment, the command list), then forwards raw input bytes and resizes and writes the finished frames it + receives. It never interprets a key. Key parsing, tables, prompts and drawing all happen in the server. +- **Rendering**: per client the server composes a full grid (panes, borders, status row, prompt, overlays), + diffs it against the previous frame by row and sends only changed rows, at most one frame per + `MPLEX_RENDER_INTERVAL`. +- **Sessions**: several clients may attach to one session; the window size is the smallest attached client. + A detached session keeps running. `kill-server` ends everything. +- **Sockets and files** (all under `/plexer/`, directory `0700`, files `0600`): + `server.sock`, `server.lock` (flock, one server per home), `server.log`, and one log per pane, + `-pane.log`, a verbatim byte log that rolls over to `.1` at `MPLEX_LOG_MAX`. + +### Wire format + +Both directions use frames: 1 byte kind, 4 bytes big-endian length, payload. A connection starts with the +single byte `0x01`; anything else is treated as one line of the JSON control protocol below. + +| Direction | Kind | Payload | +|---|---|---| +| client to server | `H` | hello JSON: `rows`, `cols`, `term`, `cwd`, `pid`, `tty`, `env`, `pane`, `commands` (list of word lists) | +| client to server | `I` | raw input bytes | +| client to server | `R` | resize JSON `{rows, cols}` | +| server to client | `A` | attached: enter raw mode and the alternate screen | +| server to client | `O` | terminal output | +| server to client | `M` | text to print on stdout (command output) | +| server to client | `E` | error text, client exits 1 | +| server to client | `D` / `X` | detached / exited (and `[server exited]`): print the text, exit 0 | +| server to client | `Z` | suspend the client (`C-z`) | + +### Control socket (JSON lines) + +The same socket serves `NativePlexerController` (the agent tools). One request line, one reply line: +`{"cmd": "send", "text": "...", "enter": true, "pane": "3"}`, `{"cmd": "read", "lines": 50}`, `{"cmd": "status"}`. +`pane` is optional and defaults to the active pane of the most recently used session; an unknown pane is an +error. `read` returns the tail of the pane's raw byte log. A malformed request returns `{"ok": false, "error": ...}` +and never disturbs the server. + +### Hard caps + +| Constant | Value | Limits | +|---|---|---| +| `MPLEX_MAX_SESSIONS` / `MPLEX_MAX_WINDOWS` / `MPLEX_MAX_PANES` / `MPLEX_MAX_CLIENTS` | 64 / 256 / 256 / 32 | objects the server will hold | +| `MPLEX_FRAME_MAX` | 4 MiB | one frame; a larger length drops the client | +| `MPLEX_CLIENT_OUT_MAX` | 4 MiB | unsent output per client; over it, frames are dropped and the next one repaints everything | +| `MPLEX_PANE_IN_MAX` | 4 MiB | queued input per pane; over it, input is dropped and counted | +| `MPLEX_HISTORY_CAP` | 200000 | the largest accepted `history-limit` | +| `MPLEX_LOG_MAX` | 16 MiB | one pane log before it rolls over (one old generation kept) | +| `MPLEX_BUFFERS_CAP` | 100 | paste buffers (`buffer-limit` default 50) | +| `MPLEX_MESSAGES_MAX` / `MPLEX_PROMPT_HISTORY_CAP` | 200 / 100 | `show-messages` entries, entries per prompt history | +| `MPLEX_COMMAND_DEPTH_MAX` | 16 | nesting of `if-shell`, `source-file`, `run-shell -C` | +| `MPLEX_LOOP_ERRORS_MAX` | 50 | failed server loop iterations in a row before the server gives up (each failure is logged and survived) | +| `MPLEX_SHELL_TIMEOUT` | 5 s | `#(...)`, `if-shell`, `run-shell`, copy commands (they block the server for that long at most) | +| `MPLEX_CONFIG_MAX` | 1 MiB | size of a config or source file | +| `MPLEX_PASTE_MAX` | 4 MiB | one paste buffer | +| `MPLEX_RENDER_INTERVAL` | 12 ms | minimum time between frames for a client | +| `MPLEX_REAP_KILL_AFTER` | 2 s | SIGHUP is escalated to SIGKILL for a pane process that ignored it | + +## Keys + +Key names use tmux notation: `a`, `C-a`, `M-a`, `C-M-a`, `S-Up`, `Space`, `Enter`, `Tab`, `BTab`, `BSpace`, `Escape`, +`Up Down Left Right Home End`, `PPage` (alias `PageUp`), `NPage` (`PageDown`), `IC` (`Insert`), `DC` (`Delete`), `F1` to `F12`, +`^a` for `C-a`. Modifier order is normalized to `C-`, `M-`, `S-`. `C-m`, `C-i`, `C-[` are `Enter`, `Tab`, `Escape`. + +Lookup order for every key from an attached client: + +1. an open prompt, then an open overlay (`display-panes`), take the key; +2. in the `root` table: a binding runs; +3. the prefix key or `prefix2` switches the client to the `prefix` table; the next key is looked up there (an unbound key is dropped); +4. otherwise the top mode of the pane takes it (copy mode uses `copy-mode` or `copy-mode-vi` according to `mode-keys`; a + key unbound there is ignored, not sent to the shell); +5. otherwise the bytes go to the pane's shell (cursor keys become `ESC O x` when the application asked for it). + +A binding flagged `repeat` keeps the client in its table for `repeat-time` ms; another repeatable key within that time runs +without the prefix, anything else leaves the table and is handled normally. A lone `Escape` is held for `escape-time` ms to +tell it from `M-key`. `switch-client -T ` enters a custom table. + +## Configuration: ~/.tai/mplex.toml + +Read when the server starts, and again by `source-file ~/.tai/mplex.toml` (applied on top of the running state). +The file is `/mplex.toml`. It needs Python 3.11 or newer (`tomllib`); on older Pythons the defaults apply and the +status line says why. Two tables are accepted: + +```toml +[options] # any option from the table below +prefix = "C-a" # main key; "None" disables +prefix2 = "F12" # second prefix +base-index = 1 +mode-keys = "vi" +status-position = "top" +status-left = "[#S] " +status-right = "#H %H:%M" +status-style = "bg=blue,fg=white" +history-limit = 10000 + +[keys] +clear = ["prefix"] # optional: empty these tables before applying the entries below + +[keys.prefix] # key tables: prefix, root, copy-mode, copy-mode-vi, or your own (switch-client -T name) +"|" = "split-window -h" # a command line: the same syntax as the command prompt, ; chains commands +"-" = "split-window -v" +"C-b" = false # false (or "") unbinds a default +r = { command = "source-file ~/.tai/mplex.toml", note = "reload config", repeat = false } +Up = { command = "select-pane -U", repeat = true } + +[keys.root] # keys that need no prefix +"M-Left" = "previous-window" +"M-Right" = "next-window" + +[keys.copy-mode-vi] +y = "send-keys -X copy-selection-and-cancel" +``` + +A key given as an inline table accepts exactly `command`, `repeat` and `note`. Keys are validated with the key grammar above; +commands are checked for existence when loaded. + +Errors never abort the load and are never silent: the offending entry is skipped, counted, the first client sees +`mplex.toml: N problem(s) skipped, see show-messages` on the status row, and each problem is listed by `show-messages` +(at most 50 are kept). Unknown options, unknown top-level tables, bad key names, unknown commands, wrong value types, out of +range numbers, invalid TOML, a file over `MPLEX_CONFIG_MAX` and a missing `tomllib` are all reported this way. + +Options set at runtime: `set-option [-g|-w] name value` (session scope without `-g`, window scope with `-w`), +`set-option -u name` unsets, `-a` appends to strings, `-o` refuses to overwrite, `-q` ignores unknown names, a bare boolean name toggles. +`bind-key`, `unbind-key` change tables at runtime but are not written back to the file. + +### Style strings + +`fg=colour`, `bg=colour`, attributes `bold dim italics underscore blink reverse hidden strikethrough` and their `no` forms, +`default`, `none`. Colours: the eight names, `bright` + name, `colour0` to `colour255`, `#rrggbb`, `default`. +Inside format strings `#[style]` switches style from that point on; `#[default]` returns to the base style. + +### Formats + +`#S #I #W #F #P #T #H #h #D`, `#{variable}`, `#{?cond,a,b}` (chains allowed), modifiers `=N: =-N: =/N/marker: E: T: q: n: b: d: +t: ! !! l: s/re/with/:`, comparisons `==: !=: <: >: <=: >=: &&: ||:`, `m:` (glob) `m/r:` (regex), `e|+ - * / %:` +arithmetic, `#(shell command)` (cached for `status-interval`), `##` for a literal `#`, `#,` and `#}` inside conditionals, +and strftime codes (`%H:%M`). Variables include the session, window, pane, client, history and copy mode values used by the +default bindings; every option name is also a variable (booleans give `1`/`0`). + +## Options + +| Option | Type | Default | +|---|---|---| +| `allow-set-title` | bool | `on` | +| `automatic-rename` | bool | `on` | +| `automatic-rename-format` | str | `#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}` | +| `base-index` | number 0..9999 | `0` | +| `buffer-limit` | number 1..100 | `50` | +| `clock-mode-colour` | colour | `blue` | +| `clock-mode-style` | 12\|24 | `24` | +| `copy-command` | str | `(empty)` | +| `copy-mode-current-match-style` | style | `bg=magenta,fg=black` | +| `copy-mode-mark-style` | style | `bg=red,fg=black` | +| `copy-mode-match-style` | style | `bg=cyan,fg=black` | +| `default-command` | str | `(empty)` | +| `default-shell` | str | `(empty)` | +| `default-terminal` | str | `tmux-256color` | +| `detach-on-destroy` | bool | `on` | +| `display-panes-active-colour` | colour | `red` | +| `display-panes-colour` | colour | `blue` | +| `display-panes-time` | number 1..600000 | `1000` | +| `display-time` | number 0..600000 | `750` | +| `escape-time` | number 0..10000 | `10` | +| `history-limit` | number 0..200000 | `2000` | +| `main-pane-height` | number 1..10000 | `24` | +| `main-pane-width` | number 1..10000 | `80` | +| `message-command-style` | style | `bg=black,fg=yellow` | +| `message-style` | style | `bg=yellow,fg=black` | +| `mode-keys` | vi\|emacs | `emacs (vi when $VISUAL or $EDITOR contains "vi")` | +| `mode-style` | style | `noattr,bg=yellow,fg=black` | +| `pane-active-border-style` | str | `#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}` | +| `pane-base-index` | number 0..9999 | `0` | +| `pane-border-lines` | single\|double\|heavy\|simple | `single` | +| `pane-border-style` | style | `default` | +| `prefix` | key | `C-b` | +| `prefix2` | key | `None` | +| `remain-on-exit` | bool | `off` | +| `renumber-windows` | bool | `off` | +| `repeat-time` | number 0..10000 | `500` | +| `set-clipboard` | on\|external\|off | `off` | +| `status` | bool | `on` | +| `status-interval` | number 0..3600 | `15` | +| `status-justify` | left\|centre\|center\|right | `left` | +| `status-keys` | vi\|emacs | `emacs (vi when $VISUAL or $EDITOR contains "vi")` | +| `status-left` | str | `[#{session_name}] ` | +| `status-left-length` | number 0..1000 | `10` | +| `status-left-style` | style | `default` | +| `status-position` | top\|bottom | `bottom` | +| `status-right` | str | `"#{=21:pane_title}" %H:%M %d-%b-%y` | +| `status-right-length` | number 0..1000 | `40` | +| `status-right-style` | style | `default` | +| `status-style` | style | `bg=green,fg=black` | +| `synchronize-panes` | bool | `off` | +| `window-status-current-format` | str | `#I:#W#{?window_flags,#{window_flags}, }` | +| `window-status-current-style` | style | `default` | +| `window-status-format` | str | `#I:#W#{?window_flags,#{window_flags}, }` | +| `window-status-last-style` | style | `default` | +| `window-status-separator` | str | ` ` | +| `window-status-style` | style | `default` | +| `word-separators` | str | `!"#$%&'()*+,-./:;<=>?@[\]^`{\|}~` | +| `wrap-search` | bool | `on` | + +`mouse`, `set-titles`, `lock-*`, `monitor-*`, `visual-*`, `update-environment` and the rest of tmux's options are not +implemented and are rejected with an error, not accepted and ignored. + +## Commands + +| Command | Aliases | Flags | Arguments | +|---|---|---|---| +| `attach-session` | `attach`, `a` | `-d` `-E` `-r` `-x` `-c arg` `-f arg` `-t arg` | 0 | +| `bind-key` | `bind` | `-n` `-r` `-T arg` `-N arg` | 1..n | +| `break-pane` | `breakp` | `-a` `-b` `-d` `-P` `-F arg` `-n arg` `-s arg` `-t arg` | 0 | +| `capture-pane` | `capturep` | `-a` `-C` `-e` `-J` `-M` `-N` `-p` `-P` `-q` `-T` `-b arg` `-E arg` `-S arg` `-t arg` | 0 | +| `choose-buffer` | | `-N` `-r` `-Z` `-F arg` `-f arg` `-K arg` `-O arg` `-t arg` | 0..1 | +| `choose-client` | | `-N` `-r` `-Z` `-F arg` `-f arg` `-K arg` `-O arg` `-t arg` | 0..1 | +| `choose-tree` | | `-G` `-N` `-r` `-s` `-w` `-Z` `-F arg` `-f arg` `-K arg` `-O arg` `-t arg` | 0..1 | +| `clear-history` | `clearhist` | `-H` `-t arg` | 0 | +| `clock-mode` | | `-t arg` | 0 | +| `command-prompt` | | `-1` `-b` `-F` `-k` `-l` `-i` `-N` `-I arg` `-p arg` `-t arg` `-T arg` | 0..1 | +| `confirm-before` | `confirm` | `-b` `-y` `-c arg` `-p arg` `-t arg` | 1 | +| `copy-mode` | | `-d` `-e` `-H` `-M` `-q` `-S` `-u` `-s arg` `-t arg` | 0 | +| `delete-buffer` | `deleteb` | `-b arg` | 0 | +| `detach-client` | `detach` | `-a` `-P` `-E arg` `-s arg` `-t arg` | 0 | +| `display-message` | `display` | `-a` `-C` `-I` `-l` `-N` `-p` `-v` `-c arg` `-d arg` `-F arg` `-t arg` | 0..1 | +| `display-panes` | `displayp` | `-b` `-N` `-d arg` `-t arg` | 0..1 | +| `find-window` | `findw` | `-C` `-i` `-N` `-r` `-T` `-Z` `-t arg` | 1 | +| `has-session` | `has` | `-t arg` | 0 | +| `if-shell` | `if` | `-b` `-F` `-t arg` | 2..3 | +| `kill-pane` | `killp` | `-a` `-t arg` | 0 | +| `kill-server` | | | 0 | +| `kill-session` | | `-a` `-C` `-t arg` | 0 | +| `kill-window` | `killw` | `-a` `-t arg` | 0 | +| `last-pane` | `lastp` | `-d` `-e` `-Z` `-t arg` | 0 | +| `last-window` | `last` | `-t arg` | 0 | +| `list-buffers` | `lsb` | `-F arg` `-f arg` | 0 | +| `list-clients` | `lsc` | `-F arg` `-f arg` `-t arg` | 0 | +| `list-commands` | `lscm` | `-F arg` | 0..1 | +| `list-keys` | `lsk` | `-1` `-a` `-N` `-P arg` `-T arg` | 0..1 | +| `list-panes` | `lsp` | `-a` `-s` `-F arg` `-f arg` `-t arg` | 0 | +| `list-sessions` | `ls` | `-F arg` `-f arg` | 0 | +| `list-windows` | `lsw` | `-a` `-F arg` `-f arg` `-t arg` | 0 | +| `load-buffer` | `loadb` | `-b arg` `-t arg` | 1 | +| `move-window` | `movew` | `-a` `-b` `-d` `-k` `-r` `-s arg` `-t arg` | 0 | +| `new-session` | `new` | `-A` `-d` `-D` `-E` `-P` `-X` `-c arg` `-e arg` `-F arg` `-f arg` `-n arg` `-s arg` `-t arg` `-x arg` `-y arg` | 0..n | +| `new-window` | `neww` | `-a` `-b` `-d` `-k` `-P` `-S` `-c arg` `-e arg` `-F arg` `-n arg` `-t arg` | 0..n | +| `next-layout` | `nextl` | `-t arg` | 0 | +| `next-window` | `next` | `-a` `-t arg` | 0 | +| `paste-buffer` | `pasteb` | `-d` `-p` `-r` `-s arg` `-b arg` `-t arg` | 0 | +| `previous-layout` | `prevl` | `-t arg` | 0 | +| `previous-window` | `prev` | `-a` `-t arg` | 0 | +| `refresh-client` | `refresh` | `-c` `-D` `-l` `-L` `-R` `-S` `-U` `-A arg` `-B arg` `-C arg` `-f arg` `-r arg` `-t arg` | 0..1 | +| `rename-session` | `rename` | `-t arg` | 1 | +| `rename-window` | `renamew` | `-t arg` | 1 | +| `resize-pane` | `resizep` | `-D` `-L` `-M` `-R` `-T` `-U` `-Z` `-x arg` `-y arg` `-t arg` | 0..1 | +| `rotate-window` | `rotatew` | `-D` `-U` `-Z` `-t arg` | 0 | +| `run-shell` | `run` | `-b` `-C` `-E` `-c arg` `-d arg` `-t arg` | 0..1 | +| `save-buffer` | `saveb` | `-a` `-b arg` | 1 | +| `select-layout` | `selectl` | `-E` `-n` `-o` `-p` `-t arg` | 0..1 | +| `select-pane` | `selectp` | `-D` `-d` `-e` `-L` `-l` `-M` `-m` `-R` `-U` `-Z` `-T arg` `-t arg` | 0 | +| `select-window` | `selectw` | `-l` `-n` `-p` `-T` `-t arg` | 0 | +| `send-keys` | `send` | `-F` `-H` `-K` `-l` `-M` `-R` `-X` `-c arg` `-N arg` `-t arg` | 0..n | +| `send-prefix` | | `-2` `-t arg` | 0 | +| `set-buffer` | `setb` | `-a` `-w` `-b arg` `-n arg` `-t arg` | 0..1 | +| `set-option` | `set` | `-a` `-F` `-g` `-o` `-p` `-q` `-s` `-u` `-U` `-w` `-t arg` | 1..2 | +| `set-window-option` | `setw` | `-a` `-F` `-g` `-o` `-q` `-u` `-t arg` | 1..2 | +| `show-buffer` | `showb` | `-b arg` | 0 | +| `show-messages` | `showmsgs` | `-J` `-T` `-t arg` | 0 | +| `show-options` | `show` | `-A` `-g` `-H` `-p` `-q` `-s` `-v` `-w` `-t arg` | 0..1 | +| `show-window-options` | `showw` | `-g` `-v` `-t arg` | 0..1 | +| `source-file` | `source` | `-F` `-n` `-q` `-v` `-t arg` | 1..n | +| `split-window` | `splitw` | `-b` `-d` `-e` `-f` `-h` `-I` `-P` `-v` `-Z` `-c arg` `-e arg` `-F arg` `-l arg` `-t arg` | 0..n | +| `start-server` | `start` | | 0 | +| `suspend-client` | `suspendc` | `-t arg` | 0 | +| `swap-pane` | `swapp` | `-d` `-D` `-U` `-Z` `-s arg` `-t arg` | 0 | +| `swap-window` | `swapw` | `-d` `-s arg` `-t arg` | 0 | +| `switch-client` | `switchc` | `-E` `-l` `-n` `-p` `-r` `-Z` `-c arg` `-t arg` `-T arg` | 0 | +| `unbind-key` | `unbind` | `-a` `-n` `-q` `-T arg` | 0..1 | + +Commands that parse but are refused with "command not supported by mplex": `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`. +Flags for mouse handling (`send-keys -M`, `copy-mode -M`, `resize-pane -M/-T`) and `select-pane -e/-d` are refused the same way. +A unique prefix of a command name is accepted (`kill-ser`). + +`send-keys -X ` drives copy mode. The commands are: `cancel`, `cursor-up`, `cursor-down`, `cursor-down-and-cancel`, +`cursor-left`, `cursor-right`, `start-of-line`, `end-of-line`, `back-to-indentation`, `top-line`, `middle-line`, `bottom-line`, +`history-top`, `history-bottom`, `page-up`, `page-down`, `page-down-and-cancel`, `halfpage-up`, `halfpage-down`, +`halfpage-down-and-cancel`, `scroll-up`, `scroll-down`, `scroll-down-and-cancel`, `scroll-middle`, `scroll-top`, `scroll-bottom`, +`cursor-centre-vertical`, `cursor-centre-horizontal`, `goto-line`, `refresh-from-pane`, `toggle-position`, `next-word`, +`next-word-end`, `next-space`, `next-space-end`, `previous-word`, `previous-space`, `next-paragraph`, `previous-paragraph`, +`next-matching-bracket`, `previous-matching-bracket`, `jump-forward`, `jump-backward`, `jump-to-forward`, `jump-to-backward`, +`jump-again`, `jump-reverse`, `begin-selection`, `stop-selection`, `clear-selection`, `select-line`, `select-word`, +`rectangle-toggle`, `rectangle-on`, `rectangle-off`, `other-end`, `set-mark`, `jump-to-mark`, `copy-selection`, +`copy-selection-and-cancel`, `copy-pipe`, `copy-pipe-and-cancel`, `append-selection`, `append-selection-and-cancel`, +`copy-end-of-line`, `copy-end-of-line-and-cancel`, `copy-pipe-end-of-line`, `copy-pipe-end-of-line-and-cancel`, `copy-line`, +`copy-line-and-cancel`, `copy-pipe-line`, `copy-pipe-line-and-cancel`, `search-forward`, `search-backward`, +`search-forward-text`, `search-backward-text`, `search-forward-incremental`, `search-backward-incremental`, `search-again`, +`search-reverse`. A copy goes to the top paste buffer, to `copy-command` (or the command given to `copy-pipe*`) when set, and +as an OSC 52 clipboard sequence when `set-clipboard` is `on` or `external`. Search is a regular expression with smart case +(case-insensitive unless the text has an uppercase letter), falling back to a literal when the expression is invalid. + +## Default key bindings + +Copied from tmux 3.6 without mouse bindings. `prefix ?` shows the notes in the running multiplexer; `list-keys` shows the +live tables, including your changes. + +### prefix table (after `C-b`) + +| Key | Command | Repeat | Note | +|---|---|---|---| +| `Space` | `next-layout` | | Select next layout | +| `!` | `break-pane` | | Break pane to a new window | +| `"` | `split-window` | | Split window vertically | +| `#` | `list-buffers` | | List all paste buffers | +| `$` | `command-prompt -I "#S" { rename-session "%%" }` | | Rename current session | +| `%` | `split-window -h` | | Split window horizontally | +| `&` | `confirm-before -p "kill-window #W? (y/n)" kill-window` | | Kill current window | +| `'` | `command-prompt -T window-target -p index { select-window -t ":%%" }` | | Prompt for window index to select | +| `(` | `switch-client -p` | | Switch to previous client | +| `)` | `switch-client -n` | | Switch to next client | +| `,` | `command-prompt -I "#W" { rename-window "%%" }` | | Rename current window | +| `-` | `delete-buffer` | | Delete the most recent paste buffer | +| `.` | `command-prompt -T target { move-window -t "%%" }` | | Move the current window | +| `/` | `command-prompt -k -p key { list-keys -1N "%%" }` | | Describe key binding | +| `0` | `select-window -t :=0` | | Select window 0 | +| `1` | `select-window -t :=1` | | Select window 1 | +| `2` | `select-window -t :=2` | | Select window 2 | +| `3` | `select-window -t :=3` | | Select window 3 | +| `4` | `select-window -t :=4` | | Select window 4 | +| `5` | `select-window -t :=5` | | Select window 5 | +| `6` | `select-window -t :=6` | | Select window 6 | +| `7` | `select-window -t :=7` | | Select window 7 | +| `8` | `select-window -t :=8` | | Select window 8 | +| `9` | `select-window -t :=9` | | Select window 9 | +| `:` | `command-prompt` | | Prompt for a command | +| `;` | `last-pane` | | Move to the previously active pane | +| `<` | `display-menu` | | Display window menu | +| `=` | `choose-buffer -Z` | | Choose a paste buffer from a list | +| `>` | `display-menu` | | Display pane menu | +| `?` | `list-keys -N` | | List key bindings | +| `C` | `customize-mode -Z` | | Customize options | +| `D` | `choose-client -Z` | | Choose and detach a client from a list | +| `E` | `select-layout -E` | | Spread panes out evenly | +| `L` | `switch-client -l` | | Switch to the last client | +| `M` | `select-pane -M` | | Clear the marked pane | +| `[` | `copy-mode` | | Enter copy mode | +| `]` | `paste-buffer -p` | | Paste the most recent paste buffer | +| `c` | `new-window` | | Create a new window | +| `d` | `detach-client` | | Detach the current client | +| `f` | `command-prompt { find-window -Z "%%" }` | | Search for a pane | +| `i` | `display-message` | | Display window information | +| `l` | `last-window` | | Select the previously current window | +| `m` | `select-pane -m` | | Toggle the marked pane | +| `n` | `next-window` | | Select the next window | +| `o` | `select-pane -t :.+` | | Select the next pane | +| `p` | `previous-window` | | Select the previous window | +| `q` | `display-panes` | | Display pane numbers | +| `r` | `refresh-client` | | Redraw the current client | +| `s` | `choose-tree -Zs` | | Choose a session from a list | +| `t` | `clock-mode` | | Show a clock | +| `w` | `choose-tree -Zw` | | Choose a window from a list | +| `x` | `confirm-before -p "kill-pane #P? (y/n)" kill-pane` | | Kill the active pane | +| `z` | `resize-pane -Z` | | Zoom the active pane | +| `{` | `swap-pane -U` | | Swap the active pane with the pane above | +| `}` | `swap-pane -D` | | Swap the active pane with the pane below | +| `~` | `show-messages` | | Show messages | +| `DC` | `refresh-client -c` | yes | Reset so the visible part of the window follows the cursor | +| `PPage` | `copy-mode -u` | | Enter copy mode and scroll up | +| `Up` | `select-pane -U` | yes | Select the pane above the active pane | +| `Down` | `select-pane -D` | yes | Select the pane below the active pane | +| `Left` | `select-pane -L` | yes | Select the pane to the left of the active pane | +| `Right` | `select-pane -R` | yes | Select the pane to the right of the active pane | +| `M-1` | `select-layout even-horizontal` | | Set the even-horizontal layout | +| `M-2` | `select-layout even-vertical` | | Set the even-vertical layout | +| `M-3` | `select-layout main-horizontal` | | Set the main-horizontal layout | +| `M-4` | `select-layout main-vertical` | | Set the main-vertical layout | +| `M-5` | `select-layout tiled` | | Select the tiled layout | +| `M-6` | `select-layout main-horizontal-mirrored` | | Set the main-horizontal-mirrored layout | +| `M-7` | `select-layout main-vertical-mirrored` | | Set the main-vertical-mirrored layout | +| `M-n` | `next-window -a` | | Select the next window with an alert | +| `M-o` | `rotate-window -D` | | Rotate through the panes in reverse | +| `M-p` | `previous-window -a` | | Select the previous window with an alert | +| `M-Up` | `resize-pane -U 5` | yes | Resize the pane up by 5 | +| `M-Down` | `resize-pane -D 5` | yes | Resize the pane down by 5 | +| `M-Left` | `resize-pane -L 5` | yes | Resize the pane left by 5 | +| `M-Right` | `resize-pane -R 5` | yes | Resize the pane right by 5 | +| `C-b` | `send-prefix` | | Send the prefix key | +| `C-o` | `rotate-window` | | Rotate through the panes | +| `C-z` | `suspend-client` | | Suspend the current client | +| `C-Up` | `resize-pane -U` | yes | Resize the pane up | +| `C-Down` | `resize-pane -D` | yes | Resize the pane down | +| `C-Left` | `resize-pane -L` | yes | Resize the pane left | +| `C-Right` | `resize-pane -R` | yes | Resize the pane right | +| `S-Up` | `refresh-client -U 10` | yes | Move the visible part of the window up | +| `S-Down` | `refresh-client -D 10` | yes | Move the visible part of the window down | +| `S-Left` | `refresh-client -L 10` | yes | Move the visible part of the window left | +| `S-Right` | `refresh-client -R 10` | yes | Move the visible part of the window right | + +### copy-mode table (`mode-keys emacs`) + +| Key | Command | Repeat | Note | +|---|---|---|---| +| `Escape` | `send-keys -X cancel` | | | +| `Space` | `send-keys -X page-down` | | | +| `,` | `send-keys -X jump-reverse` | | | +| `;` | `send-keys -X jump-again` | | | +| `F` | `command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }` | | | +| `N` | `send-keys -X search-reverse` | | | +| `P` | `send-keys -X toggle-position` | | | +| `R` | `send-keys -X rectangle-toggle` | | | +| `T` | `command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }` | | | +| `X` | `send-keys -X set-mark` | | | +| `f` | `command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }` | | | +| `g` | `command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }` | | | +| `n` | `send-keys -X search-again` | | | +| `q` | `send-keys -X cancel` | | | +| `r` | `send-keys -X refresh-from-pane` | | | +| `t` | `command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }` | | | +| `Home` | `send-keys -X start-of-line` | | | +| `End` | `send-keys -X end-of-line` | | | +| `NPage` | `send-keys -X page-down` | | | +| `PPage` | `send-keys -X page-up` | | | +| `Up` | `send-keys -X cursor-up` | | | +| `Down` | `send-keys -X cursor-down` | | | +| `Left` | `send-keys -X cursor-left` | | | +| `Right` | `send-keys -X cursor-right` | | | +| `M-1` | `command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }` | | | +| `M-2` | `command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }` | | | +| `M-3` | `command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }` | | | +| `M-4` | `command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }` | | | +| `M-5` | `command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }` | | | +| `M-6` | `command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }` | | | +| `M-7` | `command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }` | | | +| `M-8` | `command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }` | | | +| `M-9` | `command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }` | | | +| `M-<` | `send-keys -X history-top` | | | +| `M->` | `send-keys -X history-bottom` | | | +| `M-R` | `send-keys -X top-line` | | | +| `M-b` | `send-keys -X previous-word` | | | +| `M-f` | `send-keys -X next-word-end` | | | +| `M-l` | `send-keys -X cursor-centre-horizontal` | | | +| `M-m` | `send-keys -X back-to-indentation` | | | +| `M-r` | `send-keys -X middle-line` | | | +| `M-v` | `send-keys -X page-up` | | | +| `M-w` | `send-keys -X copy-pipe-and-cancel` | | | +| `M-x` | `send-keys -X jump-to-mark` | | | +| `M-{` | `send-keys -X previous-paragraph` | | | +| `M-}` | `send-keys -X next-paragraph` | | | +| `M-Up` | `send-keys -X halfpage-up` | | | +| `M-Down` | `send-keys -X halfpage-down` | | | +| `C-Space` | `send-keys -X begin-selection` | | | +| `C-a` | `send-keys -X start-of-line` | | | +| `C-b` | `send-keys -X cursor-left` | | | +| `C-c` | `send-keys -X cancel` | | | +| `C-e` | `send-keys -X end-of-line` | | | +| `C-f` | `send-keys -X cursor-right` | | | +| `C-g` | `send-keys -X clear-selection` | | | +| `C-k` | `send-keys -X copy-pipe-end-of-line-and-cancel` | | | +| `C-l` | `send-keys -X cursor-centre-vertical` | | | +| `C-n` | `send-keys -X cursor-down` | | | +| `C-p` | `send-keys -X cursor-up` | | | +| `C-r` | `command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }` | | | +| `C-s` | `command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }` | | | +| `C-v` | `send-keys -X page-down` | | | +| `C-w` | `send-keys -X copy-pipe-and-cancel` | | | +| `C-Up` | `send-keys -X scroll-up` | | | +| `C-Down` | `send-keys -X scroll-down` | | | +| `C-M-b` | `send-keys -X previous-matching-bracket` | | | +| `C-M-f` | `send-keys -X next-matching-bracket` | | | + +### copy-mode-vi table (`mode-keys vi`) + +| Key | Command | Repeat | Note | +|---|---|---|---| +| `Enter` | `send-keys -X copy-pipe-and-cancel` | | | +| `Escape` | `send-keys -X clear-selection` | | | +| `Space` | `send-keys -X begin-selection` | | | +| `#` | `send-keys -FX search-backward -- "#{copy_cursor_word}"` | | | +| `$` | `send-keys -X end-of-line` | | | +| `%` | `send-keys -X next-matching-bracket` | | | +| `*` | `send-keys -FX search-forward -- "#{copy_cursor_word}"` | | | +| `,` | `send-keys -X jump-reverse` | | | +| `/` | `command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }` | | | +| `0` | `send-keys -X start-of-line` | | | +| `1` | `command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }` | | | +| `2` | `command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }` | | | +| `3` | `command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }` | | | +| `4` | `command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }` | | | +| `5` | `command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }` | | | +| `6` | `command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }` | | | +| `7` | `command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }` | | | +| `8` | `command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }` | | | +| `9` | `command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }` | | | +| `:` | `command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }` | | | +| `;` | `send-keys -X jump-again` | | | +| `?` | `command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }` | | | +| `A` | `send-keys -X append-selection-and-cancel` | | | +| `B` | `send-keys -X previous-space` | | | +| `D` | `send-keys -X copy-pipe-end-of-line-and-cancel` | | | +| `E` | `send-keys -X next-space-end` | | | +| `F` | `command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }` | | | +| `G` | `send-keys -X history-bottom` | | | +| `H` | `send-keys -X top-line` | | | +| `J` | `send-keys -X scroll-down` | | | +| `K` | `send-keys -X scroll-up` | | | +| `L` | `send-keys -X bottom-line` | | | +| `M` | `send-keys -X middle-line` | | | +| `N` | `send-keys -X search-reverse` | | | +| `P` | `send-keys -X toggle-position` | | | +| `T` | `command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }` | | | +| `V` | `send-keys -X select-line` | | | +| `W` | `send-keys -X next-space` | | | +| `X` | `send-keys -X set-mark` | | | +| `^` | `send-keys -X back-to-indentation` | | | +| `b` | `send-keys -X previous-word` | | | +| `e` | `send-keys -X next-word-end` | | | +| `f` | `command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }` | | | +| `g` | `send-keys -X history-top` | | | +| `h` | `send-keys -X cursor-left` | | | +| `j` | `send-keys -X cursor-down` | | | +| `k` | `send-keys -X cursor-up` | | | +| `l` | `send-keys -X cursor-right` | | | +| `n` | `send-keys -X search-again` | | | +| `o` | `send-keys -X other-end` | | | +| `q` | `send-keys -X cancel` | | | +| `r` | `send-keys -X refresh-from-pane` | | | +| `t` | `command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }` | | | +| `v` | `send-keys -X rectangle-toggle` | | | +| `w` | `send-keys -X next-word` | | | +| `z` | `send-keys -X scroll-middle` | | | +| `{` | `send-keys -X previous-paragraph` | | | +| `}` | `send-keys -X next-paragraph` | | | +| `BSpace` | `send-keys -X cursor-left` | | | +| `Home` | `send-keys -X start-of-line` | | | +| `End` | `send-keys -X end-of-line` | | | +| `NPage` | `send-keys -X page-down` | | | +| `PPage` | `send-keys -X page-up` | | | +| `Up` | `send-keys -X cursor-up` | | | +| `Down` | `send-keys -X cursor-down` | | | +| `Left` | `send-keys -X cursor-left` | | | +| `Right` | `send-keys -X cursor-right` | | | +| `M-x` | `send-keys -X jump-to-mark` | | | +| `C-b` | `send-keys -X page-up` | | | +| `C-c` | `send-keys -X cancel` | | | +| `C-d` | `send-keys -X halfpage-down` | | | +| `C-e` | `send-keys -X scroll-down` | | | +| `C-f` | `send-keys -X page-down` | | | +| `C-h` | `send-keys -X cursor-left` | | | +| `C-j` | `send-keys -X copy-pipe-and-cancel` | | | +| `C-u` | `send-keys -X halfpage-up` | | | +| `C-v` | `send-keys -X rectangle-toggle` | | | +| `C-y` | `send-keys -X scroll-up` | | | +| `C-Up` | `send-keys -X scroll-up` | | | +| `C-Down` | `send-keys -X scroll-down` | | | + +`display-menu` (`<`, `>`) and `customize-mode` (`C`) are bound exactly as in tmux but report "not supported" when used. + +## Pane modes + +Copy mode (`copy-mode`, and the read-only view that `list-keys`, `show-messages` and other long output open: `view-mode`), +`clock-mode`, and the choosers `choose-tree`, `choose-buffer`, `choose-client`. Chooser keys: `Up/k/C-p`, `Down/j/C-n`, +`PPage/C-b`, `NPage/C-f`, `Home/g`, `End/G`, `Right/l/+` expand, `Left/h/-` collapse, `t` tag, `v` preview, `x` kill (asks +`y`), `Enter` choose, `0`-`9` choose the numbered entry, `q/Escape/C-c` leave. The chooser keys are fixed, not rebindable. +`display-panes` is an overlay on the client (digit selects, any other key dismisses). + +The command prompt edits like tmux's emacs mode: `C-a C-e C-b C-f M-b M-f BSpace C-h DC C-d C-k C-u C-w C-y C-t`, `Up/Down` +history (per prompt type, capped), `Enter`, `Escape/C-c/C-g`. The single key (`-1`), key name (`-k`), numeric (`-N`) and +incremental (`-i`, `C-s`/`C-r` step) variants are implemented. + +## Measurements + +Taken on the development machine (Linux, Python 3.14), one pane at 200x50 in an attached client: +an idle server used 0.000 s of CPU over 5 s; `seq 1 300000` reached the attached terminal in 1.95 s wall with 1.72 s of +server CPU. vim and htop render correctly inside a pane. No numbers exist for many panes, many clients, slow terminals or +other machines. + +## Checked against tmux 3.6 + +Behavior was compared with the tmux 3.6 installed on the development machine, command by command, instead of being written +from memory. What exists as evidence: + +- Default key tables, notes, options and command flags: dumped from tmux (research `local-tmux-3-6-keybinding-and-option-dumps.md`). +- Pane geometry: 69 scenarios (all seven layouts for 2 to 7 panes, splits with `-b` `-l` and percentages, resize in all + directions and to an absolute size, kill, rotate, swap, directional select, last-pane, zoom, break-pane, kill-pane -a) give + identical pane sizes, positions and active pane in tmux and in mplex. Unit fixtures in `MplexLayoutTests` carry the values. +- Windows: 26 scenarios (new-window `-a` `-b` `-k` and explicit indexes, gap filling, kill and what becomes current, select, last, + next, previous, `-T`, move, swap, renumber, base-index, break-pane) agree on indexes, names, flags and errors, except one flag + after `swap-window -d` (tmux marks a different window as last). +- Copy mode movement: a differential fuzz of random key sequences against tmux (12 steps per sequence; content with blanks, + indentation, punctuation and history; the search string reset before each sequence) ran clean over 360 emacs-key sequences + and over about 240 of 300 vi-key sequences; the remaining vi-key difference is the line-crossing bookkeeping described below. + 842 recorded results (782 cursor and scroll states, 60 selections and copies, both key modes) are replayed by + `MplexCopyModeVersusTmuxTests`. Rules learned that way and implemented: the cursor stands between characters and may sit one + past the line end; vertical moves, pages and scrolls remember a column (`lastcx`/`lastsx`) and snap to the line end the way + tmux does; a page is `rows - 2`; searches put the cursor at the match end for emacs keys and the match start for vi keys, and + scroll the match into view by tmux's rule; a search string outlives copy mode and restarts upward; a failed search is a status + message, not a command error; selections are half open for emacs keys and include the cursor character (and the newline at a + line end) for vi keys; `select-word` on blanks takes the previous word; `copy-line` and `copy-end-of-line` start a fresh + selection. +- Copy mode selections: the same fuzz with selections (begin, select-word, select-line, rectangle, other-end, then copies) + did not converge: roughly one random sequence in five still copies a different range, typically when `select-word` or + `select-line` is followed by more cursor moves (tmux then extends by words or lines, mplex by characters). + +## Differences from tmux and gaps + +Each item names the code that would have to change. + +- **No mouse.** No mouse reporting, selection, border dragging or wheel scroll. `MplexKeys.split` drops mouse sequences; + a client enabling mouse mode and an `MplexClientConn` routing table would be needed. +- **No reflow on resize, no wrapped-line flag.** `TuiVtScreen.resize` truncates rows when the pane gets narrower and cannot get + the text back; tmux reflows. Copy mode treats each screen row as a line, so word motions, `end-of-line` and copies do not + join a long line that the terminal wrapped, where tmux does. Fix: store a wrapped flag per row in `TuiVtScreen` and re-wrap + in `resize`; `MplexCopyMode.llen`, `next_word` and `selection_text` would then follow it. +- **Wide characters count as one column** (the existing `TuiVtScreen` gap in `tui.md`); CJK and emoji misalign. +- **Copy mode corners that differ from tmux:** the non-bracket fallback of `next-matching-bracket` and + `previous-matching-bracket` (tmux behaves like several different word motions depending on the position, mplex uses + next-space-end and previous-space); a failed `jump-*` followed by `jump-reverse`; word- and line-granular selection after + `select-word` or `select-line` plus cursor moves (mplex stays character based); the `lastcx`/`lastsx` bookkeeping when a + backward word motion crosses a line (tmux records in some situations, mplex never does); a cursor placed beyond the line + end by `goto-line`; the `swap-window -d` last-window flag. `append-selection` was not compared. +- **Window flags** show `*`, `-`, `Z`, `M`; there is no bell, activity or silence tracking, so `next-window -a` finds no + window and says so. Needs per-pane output/bell counters in `MplexServer.pane_read`. +- **`status-keys vi`** is treated as emacs. The command prompt has no tab completion. +- **Status line**: no multi-line status, `status-format`, ranges or `pane-border-status`. +- **Layouts** are the seven named presets and the splits you make; layout strings from `list-windows -F '#{window_layout}'` + are not parsed or produced. +- **Windows are sized to the smallest attached client** (`window-size smallest`); `latest` is not offered. +- **Environment**: new panes get the environment of the client that created the session, there is no `update-environment` + or `set-environment`. +- **Choosers** do not have tmux's sort order, filter, `-K`, `-O` and format flags (accepted by the parser, not used). +- **Refresh panning** (`S-Up` ...) is accepted and does nothing, since a window is never larger than its client. +- **Set-clipboard defaults to `off`** (tmux: `external`), because mplex cannot know whether the terminal understands OSC 52. +- `default-terminal` falls back to `screen-256color`, `xterm-256color`, `xterm`, then `vt100` when its terminfo entry is missing. +- Shortcuts that need a client, mouse or menu in tmux and are refused here are listed under Commands. + +## Tests + +- `test_tai.py`: `Mplex*Tests`, unit level (keys, parser, defaults, config, formats, layout, canvas, VT screen extensions, + copy mode and its tmux-recorded fixtures, sessions, prompt, templates, framing) and `MplexSpecTests`, which checks this + file against the code. +- `tests/mplex.py`: real client under a pty, real server, real bash: lifecycle, every keyboard shortcut with an effect, + copy mode round trip, `mplex.toml`, two clients, the command line. +- `tests/plexer.py`: control socket, per-pane targeting, permissions, large and malformed requests. diff --git a/tai.py b/tai.py index ef13f29..39cf355 100755 --- a/tai.py +++ b/tai.py @@ -6118,6 +6118,7 @@ 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") @@ -8591,6 +8592,14 @@ class MplexCopyMode(MplexMode): 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) @@ -8609,16 +8618,16 @@ class MplexCopyMode(MplexMode): if x < xx: x += 1 elif ay < self.total() - 1: + self.goto(0, ay + 1) x, ay = 0, ay + 1 xx = self.llen(ay) - else: - break 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 - x = self.run_end(x, ay, xx, separators) - if vi: - x -= 1 self.goto(x, ay) def c_next_space_end(self, words, client): @@ -9022,38 +9031,42 @@ class MplexCopyMode(MplexMode): self.scroll_to(end if forward and not vi else start, line) return True - def search_command(self, words, forward, literal=False): + 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): - raise MplexError("Search failed") + self.search_failed(client) def c_search_forward(self, words, client): - self.search_command(words, True) + self.search_command(words, True, False, client) def c_search_backward(self, words, client): - self.search_command(words, False) + self.search_command(words, False, False, client) def c_search_forward_text(self, words, client): - self.search_command(words, True, True) + self.search_command(words, True, True, client) def c_search_backward_text(self, words, client): - self.search_command(words, False, True) + 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): - raise MplexError("Search failed") + 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): - raise MplexError("Search failed") + self.search_failed(client) self.search = (text, forward, literal) def incremental(self, words, forward): @@ -10853,53 +10866,70 @@ class MplexServer: 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: - 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: - continue - except (OSError, ValueError): - self.sweep_bad_fds() - continue - 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 + 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) diff --git a/test_tai.py b/test_tai.py index a9e9d5d..1f8f273 100644 --- a/test_tai.py +++ b/test_tai.py @@ -5147,6 +5147,807 @@ class TuiLazyTests(unittest.TestCase): self.assertIn("under", app.render_text()) +class TuiObservedTests(unittest.TestCase): + """State a panel keeps in a list, dict, set or TuiStyle is watched: an + in-place mutation repaints the panel with no touch() call.""" + + class Board(tai.TuiPanel): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.items = ["a", "b"] + self.meta = {"rows": [[1]], "k": 1} + self.tags = {1, 2} + + def content_rows(self): + text = ",".join(str(item) for item in self.items) + str(self.meta["rows"][0]) + str(sorted(self.tags)) + return [[tai.TuiSpan(text, self.style)]] + + def mount(self): + app = tai.TuiApp() + app.rows, app.cols = 6, 40 + app.verify = True + root = tai.TuiPanel(flow=None) + board = self.Board(x=0, y=0, w=30, h=2) + root.add(board) + app.mount(root) + app.frame_bytes() + return app, board + + def assertTouches(self, app, board, action, label): + app.frame_bytes() + self.assertNotIn(board, app._damage) + action(board) + self.assertIn(board, app._damage, "%s did not touch the panel" % label) + app.frame_bytes() + + def test_every_list_mutator_touches(self): + import operator + cases = { + "append": lambda b: b.items.append("x"), "extend": lambda b: b.items.extend(["x", "y"]), + "insert": lambda b: b.items.insert(0, "x"), "remove": lambda b: b.items.remove("a"), + "pop": lambda b: b.items.pop(), "clear": lambda b: b.items.clear(), + "sort": lambda b: b.items.sort(), "reverse": lambda b: b.items.reverse(), + "iadd": lambda b: operator.iadd(b.items, ["x"]), "imul": lambda b: operator.imul(b.items, 2), + "setitem": lambda b: b.items.__setitem__(0, "z"), "slice": lambda b: b.items.__setitem__(slice(0, 1), ["p", "q"]), + "delitem": lambda b: b.items.__delitem__(0), + } + for label, action in cases.items(): + app, board = self.mount() + self.assertTouches(app, board, action, "list." + label) + + def test_every_dict_mutator_touches(self): + import operator + cases = { + "setitem": lambda b: b.meta.__setitem__("n", 2), "update": lambda b: b.meta.update({"n": 2}), + "setdefault": lambda b: b.meta.setdefault("n", 3), "pop": lambda b: b.meta.pop("k"), + "popitem": lambda b: b.meta.popitem(), "clear": lambda b: b.meta.clear(), + "delitem": lambda b: b.meta.__delitem__("k"), "ior": lambda b: operator.ior(b.meta, {"n": 1}), + } + for label, action in cases.items(): + app, board = self.mount() + self.assertTouches(app, board, action, "dict." + label) + + def test_every_set_mutator_touches(self): + import operator + cases = { + "add": lambda b: b.tags.add(9), "discard": lambda b: b.tags.discard(1), "remove": lambda b: b.tags.remove(1), + "pop": lambda b: b.tags.pop(), "clear": lambda b: b.tags.clear(), "update": lambda b: b.tags.update({7}), + "difference_update": lambda b: b.tags.difference_update({1}), + "intersection_update": lambda b: b.tags.intersection_update({1}), + "symmetric_difference_update": lambda b: b.tags.symmetric_difference_update({1, 8}), + "ior": lambda b: operator.ior(b.tags, {5}), "iand": lambda b: operator.iand(b.tags, {1}), + "isub": lambda b: operator.isub(b.tags, {1}), "ixor": lambda b: operator.ixor(b.tags, {1, 6}), + } + for label, action in cases.items(): + app, board = self.mount() + self.assertTouches(app, board, action, "set." + label) + + def test_a_mutation_shows_on_screen_with_no_touch_call(self): + app, board = self.mount() + board.items.append("zed") + self.assertIn("zed", app.frame_bytes()) + board.meta["rows"][0].append(77) + self.assertIn("77", app.frame_bytes()) + board.tags.add(4242) + self.assertIn("4242", app.frame_bytes()) + + def test_nested_containers_are_watched_to_the_depth_cap(self): + app, board = self.mount() + board.deep = {"a": [{"b": [1]}]} + app.frame_bytes() + self.assertTouches(app, board, lambda b: b.deep["a"][0]["b"].append(2), "nested") + board.items.append([1]) + self.assertTouches(app, board, lambda b: b.items[-1].append(2), "inserted") + nest = [] + node = nest + for _depth in range(tai.TUI_OBSERVE_DEPTH + 3): + node.append([]) + node = node[0] + board.nest = nest + node = board.nest + for _depth in range(tai.TUI_OBSERVE_DEPTH + 1): + node = node[0] + self.assertIs(type(node), list) + + def test_the_panel_keeps_a_copy_and_copies_travel_plain(self): + import copy + import json + import pickle + app, board = self.mount() + source = [1, 2] + board.items = source + self.assertIsNot(board.items, source) + self.assertEqual(board.items, [1, 2]) + app.frame_bytes() + source.append(3) + self.assertNotIn(board, app._damage) + self.assertIs(type(copy.deepcopy(board.items)), list) + self.assertIs(type(copy.copy(board.items)), list) + self.assertIs(type(pickle.loads(pickle.dumps(board.meta))), dict) + self.assertEqual(json.dumps(board.meta), '{"rows": [[1]], "k": 1}') + self.assertEqual(board.tags, {1, 2}) + + def test_a_container_moved_to_another_panel_belongs_to_the_new_one(self): + app, first = self.mount() + second = self.Board(x=0, y=3, w=30, h=2) + first.parent.add(second) + app.frame_bytes() + second.items = first.items + app.frame_bytes() + second.items.append("q") + self.assertIn(second, app._damage) + self.assertNotIn(first, app._damage) + self.assertNotIn("q", first.items) + + def test_private_attributes_and_unmounted_panels_are_left_alone(self): + board = self.Board() + board.items.append("x") + board._cache = [] + self.assertIs(type(board._cache), list) + self.assertIsNone(board.app) + + def test_a_style_written_in_place_repaints_every_panel_using_it(self): + app, first = self.mount() + second = self.Board(x=0, y=3, w=30, h=2) + first.parent.add(second) + shared = tai.TuiStyle(fg="red") + first.style = shared + second.style = shared + app.frame_bytes() + shared.fg = "green" + self.assertEqual({first, second}, {node for node in (first, second) if node in app._damage}) + self.assertIn("32m", app.frame_bytes()) + shared.fg = "green" + self.assertNotIn(first, app._damage) + + def test_the_whole_app_repaints_correctly_after_container_mutations(self): + """Random in-place mutations, never a touch(): every frame equals the + reference compositor (verify is on).""" + import random + rng = random.Random(5) + app, board = self.mount() + for step in range(200): + op = rng.choice(("append", "pop", "set", "tag", "meta", "sort")) + if op == "append": + board.items.append("i%d" % step) + elif op == "pop" and board.items: + board.items.pop() + elif op == "set" and board.items: + board.items[rng.randrange(len(board.items))] = "s%d" % step + elif op == "tag": + board.tags.symmetric_difference_update({rng.randrange(20)}) + elif op == "meta": + board.meta["rows"][0].append(step) + elif op == "sort": + board.items.sort() + app.frame_bytes() + self.assertGreater(app.verified, 150) + + +class TuiLayoutTests(unittest.TestCase): + """A layout pass arranges only the subtrees that asked, each from the + nearest ancestor whose size does not follow its content, and the result + is exactly what arranging the whole tree would give (app.verify checks + that on every partial pass).""" + + class Probe(tai.TuiPanel): + log = None + + def arrange(self, x, y, w, h, clip): + if TuiLayoutTests.Probe.log is not None: + TuiLayoutTests.Probe.log.append(self.id) + super().arrange(x, y, w, h, clip) + + def setUp(self): + Probe = self.Probe + self.app = tai.TuiApp() + self.app.rows, self.app.cols = 30, 90 + self.app.verify = True + self.root = Probe(id="root", flow=None) + self.page = Probe(id="page", x=0, y=0, w="100%", h="100%", flow="cols") + self.left = Probe(id="left", w=20, h="100%", flow="rows") + self.right = Probe(id="right", w="1fr", h="100%", flow="rows") + self.page.add(self.left, self.right) + for n in range(3): + self.left.add(tai.TuiText("l%d" % n, id="l%d" % n)) + self.right.add(tai.TuiText("r%d" % n, id="r%d" % n)) + self.root.add(self.page) + self.app.mount(self.root) + self.app.frame_bytes() + + def tearDown(self): + self.Probe.log = None + + def arranged(self, action): + self.app.frame_bytes() + self.app.verify = False + self.Probe.log = [] + action() + self.app.frame_bytes() + seen, self.Probe.log = self.Probe.log, None + return seen + + def test_a_child_added_inside_a_fixed_size_panel_arranges_only_that_panel(self): + seen = self.arranged(lambda: self.left.add(tai.TuiText("more"))) + self.assertIn("left", seen) + self.assertNotIn("right", seen) + self.assertNotIn("page", seen) + self.assertNotIn("root", seen) + + def test_a_panels_own_size_change_arranges_its_parent(self): + seen = self.arranged(lambda: setattr(self.left, "w", 30)) + self.assertIn("page", seen) + self.assertNotIn("root", seen) + self.assertEqual(self.right.ax, 30) + + def test_growth_of_a_content_sized_panel_climbs_to_the_first_fixed_one(self): + self.left.w = "auto" + self.app.frame_bytes() + seen = self.arranged(lambda: setattr(self.root.find("l0"), "text", "a much longer label than before")) + self.assertIn("page", seen) + self.assertNotIn("root", seen) + self.assertEqual(self.right.ax, len("a much longer label than before")) + + def test_text_of_a_content_sized_panel_that_changes_size_relays_out(self): + self.left.w = "auto" + self.app.frame_bytes() + before = self.right.ax + self.root.find("l1").text = "x" * 40 + self.app.frame_bytes() + self.assertEqual(self.right.ax, 40) + self.assertNotEqual(before, 40) + + def test_z_and_layer_changes_arrange_nothing(self): + seen = self.arranged(lambda: setattr(self.left, "z", 4)) + self.assertEqual(seen, []) + seen = self.arranged(lambda: setattr(self.left, "layer", "overlay")) + self.assertEqual(seen, []) + + def test_quiet_writes_arrange_nothing(self): + seen = self.arranged(lambda: (setattr(self.left, "w", 20), setattr(self.right, "visible", True))) + self.assertEqual(seen, []) + + def test_hiding_and_showing_a_panel_moves_its_neighbours(self): + self.left.visible = False + self.app.frame_bytes() + self.assertEqual(self.right.ax, 0) + self.left.visible = True + self.app.frame_bytes() + self.assertEqual(self.right.ax, 20) + + def test_removed_panels_leave_no_stale_cells(self): + self.right.remove(self.root.find("r1")) + self.app.frame_bytes() + self.assertNotIn("r1", self.app.render_text()) + + def test_an_anonymous_dirty_layout_arranges_everything(self): + seen = self.arranged(self.app.dirty_layout) + self.assertIn("root", seen) + + def test_too_many_requests_fall_back_to_a_full_pass(self): + def spam(): + for n in range(tai.TuiApp.LAYOUT_STARTS + 5): + self.left.add(tai.TuiText("n%d" % n)) + seen = self.arranged(spam) + self.assertIn("root", seen) + + def test_random_trees_partial_equals_full(self): + """A seeded storm over random trees (every size spec, docks, floats, + borders, padding, visibility, adds, removes, text growth). Every + partial pass is checked against a full arrange and every frame + against the reference compositor.""" + import random + for seed in range(6): + rng = random.Random(seed) + app = tai.TuiApp() + app.rows, app.cols = 30, 90 + app.verify = True + root = tai.TuiPanel(flow=None, id="root") + containers = [root] + nodes = [] + + def spec(): + return rng.choice(["auto", "auto", 3, 8, 14, "25%", "50%", "100%", "1fr", "2fr"]) + + def make(): + kw = dict(w=spec(), h=spec()) + if rng.random() < .25: + kw["border"] = True + if rng.random() < .15: + kw["padding"] = rng.choice([1, (1, 2), 0]) + if rng.random() < .15: + kw.update(x=rng.choice([0, 3, "center", -2]), y=rng.choice([0, 2, "center"])) + elif rng.random() < .12: + kw["dock"] = rng.choice(["top", "bottom", "left", "right"]) + if rng.random() < .5: + return tai.TuiText("t" * rng.randrange(1, 30), **kw) + return tai.TuiPanel(flow=rng.choice(["rows", "cols", None]), gap=rng.choice([0, 0, 1]), **kw) + + def adopt(node): + rng.choice(containers).add(node) + nodes.append(node) + if not isinstance(node, tai.TuiText): + containers.append(node) + + for _n in range(12): + adopt(make()) + app.mount(root) + app.frame_bytes() + values = { + "w": spec, "h": spec, "padding": lambda: tai.TuiPanel.norm_padding(rng.choice([0, 1, (1, 2)])), + "gap": lambda: rng.choice([0, 1, 2]), "border": lambda: rng.random() < .5, + "x": lambda: rng.choice([None, 0, 5, "center"]), "y": lambda: rng.choice([None, 0, 3]), + "flow": lambda: rng.choice(["rows", "cols", None]), "align": lambda: rng.choice(["left", "center", "right"]), + "valign": lambda: rng.choice(["top", "center", "bottom"]), "dock": lambda: rng.choice([None, "top", "left"]), + "offset": lambda: rng.choice([(0, 0), (1, 1), (-1, 2)])} + for _step in range(150): + if len(nodes) < 3: + adopt(make()) + op = rng.choice(["set", "set", "set", "text", "add", "remove", "visible", "z"]) + node = rng.choice(nodes) + if op == "set": + name = rng.choice(sorted(values)) + setattr(node, name, values[name]()) + elif op == "text" and isinstance(node, tai.TuiText): + node.text = "t" * rng.randrange(1, 40) + "\n" * rng.randrange(0, 3) + elif op == "add": + adopt(make()) + elif op == "remove" and node.parent is not None: + node.parent.remove(node) + gone = {id(item) for item in node.walk()} + nodes[:] = [item for item in nodes if id(item) not in gone] + containers[:] = [item for item in containers if id(item) not in gone] + elif op == "visible": + node.visible = not node.visible + elif op == "z": + node.z = rng.randrange(0, 4) + app.frame_bytes() + self.assertGreater(app.layouts["partial"], 100) + + def test_a_diverging_partial_layout_is_caught(self): + """The oracle works: a partial pass that skips a panel it should have + arranged raises.""" + original = self.app._layout_roots + self.app._layout_roots = lambda starts: [] + self.right.w = "3fr" + self.left.w = 33 + with self.assertRaises(tai.TuiError): + self.app.frame_bytes() + self.app._layout_roots = original + + +class TuiScrollTests(unittest.TestCase): + """Scrolling a log is a terminal scroll plus the rows that are new, and a + log reuses the converted rows of lines it has already painted. The bytes + are replayed through the VT emulator, so a wrong scroll cannot hide.""" + + @staticmethod + def chars(grid): + return [("".join(cell[0] if cell else " " for cell in row)).rstrip() for row in grid] + + def build(self, rows=24, cols=80, lines=120, border=False, padding=0, cls=None): + app = tai.TuiApp() + app.rows, app.cols = rows, cols + app.verify = True + root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black")) + side = tai.TuiRows(dock="left", w=14) + side.add(*[tai.TuiText("side %d" % n) for n in range(5)]) + log = (cls or tai.TuiLog)(w="100%", h="100%", border=border, padding=padding) + for n in range(lines): + log.append("line %03d %s" % (n, "abcdefghij" * (n % 5)), tai.TuiStyle(fg="red" if n % 3 else "green")) + root.add(side, log) + app.mount(root) + vt = tai.TuiVtScreen(rows, cols) + vt.feed(app.frame_bytes().encode("utf-8")) + return app, log, vt + + def same(self, app, vt): + want = app.compositor.compose_reference(app.root, app.rows, app.cols) + self.assertEqual(self.chars(vt.grid), tai.TuiCompositor.render_text(want).split("\n")) + + def test_wheel_scroll_is_a_terminal_scroll_and_stays_exact(self): + import random + rng = random.Random(11) + for cls in (tai.TuiLog, tai.TuiChatLog): + app, log, vt = self.build(cls=cls) + for _step in range(120): + app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction=rng.choice(("up", "down", "up")))) + vt.feed(app.frame_bytes().encode("utf-8")) + self.same(app, vt) + self.assertGreater(app.compositor.stats["scrolled"], 500, cls.__name__) + + def test_a_scroll_frame_writes_far_less_than_a_cell_rewrite(self): + app, log, vt = self.build(rows=40, cols=120, lines=300) + log.scroll_by(0, -3) + app.frame_bytes() + app.verify = False + stats = dict(app.compositor.stats) + log.scroll_by(0, -3) + out = app.frame_bytes() + emitted = app.compositor.stats["emitted"] - stats["emitted"] + self.assertTrue("\x1bM" in out or "\x1bD" in out, "no terminal scroll was emitted") + self.assertLess(emitted, 40 * 106 // 4, "a 3 line scroll rewrote %d cells" % emitted) + + def test_full_width_log_scrolls_without_repairing_strips(self): + app = tai.TuiApp() + app.rows, app.cols = 20, 60 + app.verify = True + root = tai.TuiPanel(flow=None) + log = tai.TuiLog(x=0, y=0, w="100%", h="100%") + for n in range(100): + log.append("row %03d" % n) + root.add(log) + app.mount(root) + vt = tai.TuiVtScreen(20, 60) + vt.feed(app.frame_bytes().encode("utf-8")) + for direction in ("up", "up", "down", "down", "up"): + app.dispatch(tai.TuiScroll(x=1, y=1, direction=direction)) + vt.feed(app.frame_bytes().encode("utf-8")) + self.same(app, vt) + self.assertEqual(app.compositor.stats["scrolled"], 5 * 20) + + def test_bordered_and_padded_logs_scroll_exactly(self): + for border, padding in ((True, 0), (False, 1), (True, (1, 2))): + app, log, vt = self.build(border=border, padding=padding) + for direction in ("up", "up", "down", "up", "down", "down"): + app.dispatch(tai.TuiScroll(x=log.ax + 3, y=log.ay + 3, direction=direction)) + vt.feed(app.frame_bytes().encode("utf-8")) + self.same(app, vt) + + def test_appending_while_pinned_scrolls(self): + app, log, vt = self.build() + app.verify = True + for n in range(30): + log.append("fresh %d" % n) + vt.feed(app.frame_bytes().encode("utf-8")) + self.same(app, vt) + self.assertGreater(app.compositor.stats["scrolled"], 0) + + def test_a_wrong_hint_is_ignored_and_a_broken_scroll_is_caught(self): + app, log, vt = self.build() + log.scroll_y = 10 + vt.feed(app.frame_bytes().encode("utf-8")) + before = app.compositor.stats["scrolled"] + log.scroll_y = 10 + log._shift = 7 + log.touch() + vt.feed(app.frame_bytes().encode("utf-8")) + self.same(app, vt) + self.assertEqual(app.compositor.stats["scrolled"], before) + compositor_scroll = tai.TuiCompositor.scroll + + def broken(self, shifts, fresh, cols, rows, out): + believed = [list(row) for row in self.last] + strips = compositor_scroll(self, shifts, fresh, cols, rows, out) + if strips: + self.last[:] = believed + return strips + + tai.TuiCompositor.scroll = broken + try: + app.verify = False + with self.assertRaises(AssertionError): + for _n in range(10): + app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction="up")) + vt.feed(app.frame_bytes().encode("utf-8")) + self.same(app, vt) + finally: + tai.TuiCompositor.scroll = compositor_scroll + + def test_random_scroll_storm_with_overlays_and_resizes(self): + import random + for seed in range(8): + rng = random.Random(seed) + app, log, vt = self.build(rows=rng.randrange(12, 40), cols=rng.randrange(40, 110), lines=rng.randrange(40, 200), + border=rng.random() < .4, padding=rng.choice([0, 0, 1]), cls=rng.choice([tai.TuiLog, tai.TuiChatLog])) + dialogs = [] + for step in range(120): + op = rng.choice(("wheel", "wheel", "wheel", "append", "page", "dialog", "close", "resize", "scrollx")) + if op == "wheel": + app.dispatch(tai.TuiScroll(x=log.ax + 1, y=log.ay + 1, direction=rng.choice(("up", "down")))) + elif op == "append": + log.append("new %d %s" % (step, "y" * rng.randrange(0, 60))) + elif op == "page": + log.scroll_by(0, rng.choice((-30, -7, -1, 1, 5, 40))) + elif op == "scrollx": + log.scroll_x = rng.choice((0, 0, 2, 5)) + elif op == "dialog" and len(dialogs) < 2: + dialog = tai.TuiQuestion("Pick", ["one", "two"], on_done=lambda value: None, title="Q", w=rng.randrange(20, 36)) + app.root.add(dialog) + dialogs.append(dialog) + elif op == "close" and dialogs: + dialogs.pop().close() + elif op == "resize": + app.rows, app.cols = rng.randrange(12, 40), rng.randrange(40, 110) + app.compositor.invalidate() + app.dirty_layout() + vt = tai.TuiVtScreen(app.rows, app.cols) + vt.feed(app.frame_bytes().encode("utf-8")) + if step % 5 == 0: + self.same(app, vt) + + def test_log_cells_equal_the_span_paint(self): + """The reused-row path must give the cells the plain paint gives, for + every border, padding, scroll and width, memo cold and warm.""" + import random + rng = random.Random(3) + for cls in (tai.TuiLog, tai.TuiChatLog): + for _case in range(40): + app = tai.TuiApp() + app.rows, app.cols = 20, 70 + root = tai.TuiPanel(flow=None) + log = cls(x=rng.randrange(0, 5), y=rng.randrange(0, 3), w=rng.randrange(1, 60), h=rng.randrange(1, 18), + border=rng.random() < .4, padding=rng.choice([0, 1, (1, 2), (0, 1, 2, 0)]), + style=tai.TuiStyle(bg=rng.choice([None, "blue"]))) + for n in range(rng.randrange(0, 60)): + log.append("line %d %s" % (n, "é" * rng.randrange(0, 40)), tai.TuiStyle(fg=rng.choice(["red", "green"]))) + root.add(log) + app.mount(root) + app.frame_bytes() + log.scroll_x = rng.choice((0, 0, 3)) + log.scroll_y = rng.randrange(0, 30) + log.scroll_y = log.scroll_y + compositor = app.compositor + for _pass in range(2): + want = compositor.cells_from(log.paint(), log.aw) + self.assertEqual(log.paint_cells(compositor), want, "%s border=%s padding=%s" % (cls.__name__, log.border, log.padding)) + + def test_a_scroll_converts_only_new_lines(self): + app, log, vt = self.build(lines=400) + app.verify = False + log.scroll_by(0, -2) + app.frame_bytes() + compositor = app.compositor + converted = [] + original = compositor.cells_from + + def counting(painted, width): + converted.append(len(painted)) + return original(painted, width) + + compositor.cells_from = counting + log.scroll_by(0, -3) + app.frame_bytes() + self.assertEqual(sum(converted), 3) + + def test_the_memo_is_capped(self): + app, log, vt = self.build(lines=tai.TUI_ROW_MEMO + 300, rows=30) + app.verify = False + for pos in range(0, tai.TUI_ROW_MEMO + 300, 20): + log.scroll_y = pos + app.frame_bytes() + self.assertLessEqual(len(log._row_memo), tai.TUI_ROW_MEMO) + + +class TuiLoopTests(unittest.TestCase): + """The main loop sleeps until something is due. These run the real _main + on pipes (a stand-in for the terminal) and measure wakeups and latency.""" + + def drive(self, app, driver, hold=0.6, finish=True): + """Run app._main with stdin and stdout on pipes; driver(write_fd, + frames) runs on a thread, then the app quits (unless finish is False: + the driver must end the loop itself). Returns the list of (time, + bytes) frames.""" + import asyncio + import threading + stdin_r, stdin_w = os.pipe() + out_r, out_w = os.pipe() + os.set_blocking(out_r, False) + app._stdin_fd = stdin_r + app._stdout_fd = out_w + frames = [] + original = app.frame_bytes + + def counted(): + data = original() + if data: + frames.append((time.time(), len(data))) + return data + + app.frame_bytes = counted + + def run_driver(): + time.sleep(0.15) + driver(stdin_w, frames) + time.sleep(hold) + if finish: + app.quit() + app.wake() + + thread = threading.Thread(target=run_driver, daemon=True) + thread.start() + try: + asyncio.run(app._main()) + finally: + thread.join(timeout=5) + for fd in (stdin_r, stdin_w, out_r, out_w): + os.close(fd) + return frames + + def scene(self): + app = tai.TuiApp() + app.rows, app.cols = 10, 40 + app.verify = True + root = tai.TuiRows() + label = tai.TuiText("-", id="label") + box = tai.TuiTextBox(id="box", focusable=True) + root.add(label, box) + app.mount(root) + return app, label, box + + def test_an_idle_screen_wakes_almost_never(self): + app, label, box = self.scene() + frames = self.drive(app, lambda w, f: time.sleep(1.2), hold=0.0) + self.assertLessEqual(app.iterations, 4, "an idle loop ran %d times in 1.3 s" % app.iterations) + self.assertEqual(len(frames), 1) + + def test_a_key_reaches_the_screen_in_milliseconds(self): + app, label, box = self.scene() + latency = [] + + def driver(write_fd, frames): + for char in b"abc": + before = len(frames) + started = time.time() + os.write(write_fd, bytes([char])) + while len(frames) == before and time.time() - started < 1.0: + time.sleep(0.0005) + latency.append(frames[-1][0] - started if len(frames) > before else 9.9) + time.sleep(0.1) + + self.drive(app, driver, hold=0.1) + self.assertEqual(box.value, "abc") + self.assertLess(max(latency), 0.05, latency) + + def test_a_touch_from_another_thread_wakes_the_loop(self): + app, label, box = self.scene() + latency = [] + + def driver(write_fd, frames): + for n in range(3): + before = len(frames) + started = time.time() + label.text = "thread %d" % n + while len(frames) == before and time.time() - started < 1.0: + time.sleep(0.0005) + latency.append(frames[-1][0] - started if len(frames) > before else 9.9) + time.sleep(0.2) + + self.drive(app, driver, hold=0.1) + self.assertLess(max(latency), 0.08, latency) + + def test_timers_fire_on_time_without_polling(self): + app, label, box = self.scene() + fired = [] + started = [] + + def driver(write_fd, frames): + started.append(time.time()) + app.after(0.3, lambda: fired.append(time.time())) + app.after(0.45, lambda: setattr(label, "text", "timer")) + time.sleep(0.7) + + frames = self.drive(app, driver, hold=0.05) + self.assertEqual(len(fired), 1) + self.assertLess(abs(fired[0] - started[0] - 0.3), 0.06) + self.assertEqual(label.text, "timer") + self.assertLessEqual(app.iterations, 8) + + def test_animation_runs_at_the_frame_rate_then_stops(self): + app, label, box = self.scene() + mover = tai.TuiProgress(0.0, w=20) + app.root.add(mover) + + def driver(write_fd, frames): + mover.animate("value", 1.0, duration=0.4) + time.sleep(0.5) + + frames = self.drive(app, driver, hold=0.5) + self.assertEqual(mover.value, 1.0) + self.assertGreaterEqual(len(frames), 8) + self.assertLessEqual(app.iterations, 8 + int(0.4 * app.fps) + 6) + + def test_half_a_key_sequence_is_flushed_as_escape(self): + app, label, box = self.scene() + seen = [] + app.root.on_key = lambda event: seen.append(event.key) + + def driver(write_fd, frames): + os.write(write_fd, b"\x1b") + time.sleep(0.2) + + self.drive(app, driver, hold=0.0) + self.assertIn("escape", seen) + + def test_a_resize_signal_wakes_the_loop(self): + app, label, box = self.scene() + app.rows = 5 + app.cols = 20 + + def driver(write_fd, frames): + app._on_winch(None, None) + time.sleep(0.2) + + self.drive(app, driver, hold=0.0) + self.assertEqual((app.rows, app.cols), (24, 80)) + + def test_ticking_panels_run_every_frame_and_only_then(self): + app, label, box = self.scene() + ticks = [] + + class Ticker(tai.TuiPanel): + def on_tick(self, event): + ticks.append(event.now) + + self.drive(app, lambda w, f: time.sleep(0.4), hold=0.0) + self.assertEqual(ticks, []) + app2, label2, box2 = self.scene() + app2.root.add(Ticker(w=1, h=1)) + self.drive(app2, lambda w, f: time.sleep(0.4), hold=0.0) + self.assertGreaterEqual(len(ticks), 8) + + def test_ctrl_c_and_end_of_input_end_the_loop_at_once(self): + for ending in (b"\x03", None): + app, label, box = self.scene() + marks = [] + + def driver(write_fd, frames): + marks.append(time.time()) + if ending is None: + os.close(write_fd) + else: + os.write(write_fd, ending) + time.sleep(2.0) + + import asyncio + import threading + stdin_r, stdin_w = os.pipe() + out_r, out_w = os.pipe() + app._stdin_fd = stdin_r + app._stdout_fd = out_w + + def run_driver(): + time.sleep(0.15) + driver(stdin_w, []) + + thread = threading.Thread(target=run_driver, daemon=True) + thread.start() + asyncio.run(app._main()) + ended = time.time() + for fd in (stdin_r, out_r, out_w) + ((stdin_w,) if ending is not None else ()): + os.close(fd) + self.assertLess(ended - marks[0], 0.3, "the loop kept sleeping after %r" % (ending,)) + + def test_wake_is_safe_without_a_loop(self): + app = tai.TuiApp() + app.wake() + app.dirty_paint() + + def test_chat_has_no_polling_timers_when_idle(self): + app = tai.TuiApp() + app.rows, app.cols = 30, 100 + root, refs = tai.tui_build_chat(app, FakeChatAgent()) + app.mount(root) + repeating = [handle for handle in app.timers if handle[1] is not None] + self.assertEqual(len(repeating), 1, "idle chat timers: %r" % [handle[1] for handle in repeating]) + self.assertIsNone(refs.get("spin_timer")) + + def test_composer_resizes_from_an_event_not_a_timer(self): + app = tai.TuiApp() + app.rows, app.cols = 30, 100 + root, refs = tai.tui_build_chat(app, FakeChatAgent()) + app.mount(root) + box = refs["box"] + app.frame_bytes() + height = box.h + box.lines = ["a", "b", "c"] + self.assertEqual(box.h, 3) + box.lines.append("d") + self.assertEqual(box.h, 4) + box.lines = ["a"] + self.assertEqual(box.h, height) + + class TuiEventTests(unittest.TestCase): def test_bubble_order_and_stop(self): order = [] @@ -7631,8 +8432,12 @@ class MplexStubServer: self.config = tai.MplexConfig() self.options = tai.MplexOptions() self.copied = [] + self.messages = [] self.dirty = 0 + def message(self, client, text, milliseconds=None): + self.messages.append(text) + def pane_options(self, pane): return self.options @@ -8566,10 +9371,10 @@ class MplexCopyModeTests(unittest.TestCase): self.run_cmd(mode, "history-top") self.run_cmd(mode, "search-forward", "apple") self.assertEqual(mode.abs_y(), 0) - with self.assertRaises(tai.MplexError): - self.run_cmd(mode, "search-forward", "zebra") - with self.assertRaises(tai.MplexError): - self.run_cmd(mode, "search-forward", "(unbalanced") + self.run_cmd(mode, "search-forward", "zebra") + self.run_cmd(mode, "search-forward", "(unbalanced") + self.assertEqual(server.messages, ["Search failed", "Search failed"]) + self.assertEqual(mode.abs_y(), 0) def test_select_word_on_blanks_takes_the_previous_word(self): server, pane, mode = self.make("alpha beta", rows=4, cols=30) @@ -8791,7 +9596,7 @@ class MplexSpecTests(unittest.TestCase): def test_every_cap_is_documented(self): for name in ("MPLEX_MAX_SESSIONS", "MPLEX_MAX_WINDOWS", "MPLEX_MAX_PANES", "MPLEX_MAX_CLIENTS", "MPLEX_FRAME_MAX", "MPLEX_CLIENT_OUT_MAX", "MPLEX_PANE_IN_MAX", "MPLEX_HISTORY_CAP", "MPLEX_LOG_MAX", "MPLEX_BUFFERS_CAP", "MPLEX_MESSAGES_MAX", "MPLEX_PROMPT_HISTORY_CAP", - "MPLEX_COMMAND_DEPTH_MAX", "MPLEX_SHELL_TIMEOUT", "MPLEX_CONFIG_MAX", "MPLEX_PASTE_MAX", "MPLEX_RENDER_INTERVAL", "MPLEX_REAP_KILL_AFTER"): + "MPLEX_COMMAND_DEPTH_MAX", "MPLEX_LOOP_ERRORS_MAX", "MPLEX_SHELL_TIMEOUT", "MPLEX_CONFIG_MAX", "MPLEX_PASTE_MAX", "MPLEX_RENDER_INTERVAL", "MPLEX_REAP_KILL_AFTER"): self.assertIn("`%s`" % name, self.doc, name) self.assertTrue(hasattr(tai, name), name) @@ -9013,6 +9818,44 @@ class MplexFramingTests(unittest.TestCase): self.assertIsNone(client.prev) +class MplexServerLoopTests(unittest.TestCase): + def make(self, behaviour): + server = tai.MplexServer.__new__(tai.MplexServer) + server.running = True + server.calls = 0 + + def iterate(): + server.calls += 1 + behaviour(server) + + server.iterate = iterate + return server + + def test_a_failing_iteration_is_survived_and_logged(self): + def behaviour(server): + if server.calls <= 2: + raise RuntimeError("boom %d" % server.calls) + server.running = False + + server = self.make(behaviour) + err = io.StringIO() + with contextlib.redirect_stderr(err): + tai.MplexServer.loop(server) + self.assertEqual(server.calls, 3) + self.assertIn("boom 1", err.getvalue()) + self.assertIn("boom 2", err.getvalue()) + + def test_endless_failures_end_the_loop(self): + def behaviour(server): + raise RuntimeError("always") + + server = self.make(behaviour) + with contextlib.redirect_stderr(io.StringIO()): + tai.MplexServer.loop(server) + self.assertEqual(server.calls, tai.MPLEX_LOOP_ERRORS_MAX) + self.assertFalse(server.running) + + class MplexPaneEnvTests(unittest.TestCase): def test_controller_targets_its_pane_from_the_environment(self): with mock.patch.dict(os.environ, {tai.PLEXER_SOCKET_ENV: "/tmp/x.sock", tai.PLEXER_PANE_ENV: "7"}, clear=False):