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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:47:14 +02:00
co-authored by Claude Sonnet 5.5
parent dfc97c31be
commit 46682d42c3
4 changed files with 1618 additions and 64 deletions
+1 -1
View File
@@ -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.
+681
View File
@@ -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 <any command> 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=<server socket>` and `TAI_PLEXER_PANE=<id>` 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 `<TAI_HOME>/plexer/`, directory `0700`, files `0600`):
`server.sock`, `server.lock` (flock, one server per home), `server.log`, and one log per pane,
`<server start>-pane<id>.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 <table>` 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 `<TAI_HOME>/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 <command>` 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.
+88 -58
View File
@@ -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)
+848 -5
View File
@@ -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):