From dfc97c31be927e5b9daeb822135618b38879c82d Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 12 Oct 2026 17:02:51 +0200 Subject: [PATCH] Update. --- AGENTS.md | 1 + README.md | 22 +- research/INDEX.md | 11 +- .../multiplexer-proposal-design-basis.md | 3 +- .../tmux-parity-native-multiplexer.md | 53 + ...al-tmux-3-6-keybinding-and-option-dumps.md | 54 + tai.py | 7097 ++++++++++++++++- test_tai.py | 1865 +++++ tests/bashrc.py | 16 + tests/mplex.py | 578 ++ tests/plexer.py | 231 +- tui.md | 9 + 12 files changed, 9534 insertions(+), 406 deletions(-) create mode 100644 research/shell-multiplexer-auto-attach/conclusions/tmux-parity-native-multiplexer.md create mode 100644 research/shell-multiplexer-auto-attach/local-tmux-3-6-keybinding-and-option-dumps.md create mode 100644 tests/mplex.py diff --git a/AGENTS.md b/AGENTS.md index cf7f511..278f7fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - `tai.py` is the entire product in one standalone stdlib-only file: CLI entry plus the stable `import tai` surface, grouped into classes (instance classes for stateful clusters, static utility classes for stateless groups). It must import nothing outside the standard library; a bare copy of the file has to import and run anywhere. Canonical patch targets are `tai..` (or the module attribute for module-level data). - There are two kinds of test, in two different places, on purpose — see "Testing" below for the full split. In short: unit tests are top-level `test_*.py`; integration tests are `tests/.py` (no `test_` prefix) built on `tai_test.py`. - The in-`tai.py` TUI framework is documented in [tui.md](tui.md) — read it before touching any `Tui*` code. +- The terminal multiplexer (`Mplex*` classes, `tai.py --mplex`, `~/.tai/mplex.toml`) is specified in [mplex.md](mplex.md): read it before touching any `Mplex*` code, and keep its option, command and key tables in step with the code (`MplexSpecTests` fails when they drift). ## Testing diff --git a/README.md b/README.md index 3c98d14..a4f1f7d 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,29 @@ Eleven install targets exist side by side: `binary` (`~/.local/bin/tai.py`); a c A plexer session is a terminal tai can see into and type into, not just read a one-shot command's output from. There are two ways one gets active, and tai never cares which: - **tmux** — if the shell is already inside a tmux pane (`$TMUX_PANE` set), that pane *is* the session. tai drives it with plain `tmux send-keys`/`capture-pane`/`list-panes`; nothing is spawned. -- **native wrapper** — if nothing already owns the terminal, the plexer-hook/`zsh-plexer-hook`/`fish-plexer-hook` installed above re-execs the fresh interactive shell into `tai.py --plexer-wrap`, which forks the user's real shell onto a pty (`NativePlexerBackend`) and relays bytes between the real terminal and that pty in both directions, forwarding resize (`SIGWINCH`), teeing everything to a log file under `~/.tai/plexer/`, and serving control commands over a Unix socket (`TAI_PLEXER_SOCKET`) so a separate tai process — the agent the user is actually talking to — can drive the same session from outside. +- **native multiplexer (mplex)**: if nothing already owns the terminal, the plexer-hook/`zsh-plexer-hook`/`fish-plexer-hook` installed above re-execs the fresh interactive shell into `tai.py --plexer-wrap`, which starts (or joins) tai's own tmux-style multiplexer and attaches the terminal to a new session. One detached server per `TAI_HOME` owns every pty, window layout and scrollback; the terminal is a thin client, so detaching leaves everything running and `tai.py --mplex attach` brings it back. Every pane's output is also written to a log under `~/.tai/plexer/`, and the server answers control commands on a Unix socket (`TAI_PLEXER_SOCKET`), with the pane id in `TAI_PLEXER_PANE`, so a separate tai process (the agent the user is actually talking to) can drive the pane it was started in. Either way, four tools appear only while a session is actually active (`plexer_send`, `plexer_read`, `plexer_type`, `plexer_status` — hidden otherwise, not just disabled, via `TOOL_ENV_GATES`): `plexer_send` types literal keystrokes (optionally pressing Enter), `plexer_read` reads back the last N lines of real rendered output, `plexer_type` does the same as `send` but one character at a time with a delay for a visible typing effect, and `plexer_status` reports which backend is active and its details (pid/socket/log, or tmux pane id). Which backend answers a given call is autodetected from the environment (`TAI_PLEXER_SOCKET` vs `$TMUX_PANE`), never configured by hand. -Inside a natively-wrapped session, Ctrl+A (deliberately not tmux's Ctrl+B, so the two never collide if one is somehow nested in the other) is reserved as tai's own prefix key; `Ctrl+A q` kills the wrapped shell outright (`SIGKILL` to its process group — this is a clean kill, not a tmux-style detach-and-reattach, since there is no session persistence to reattach to). Everything else typed passes through untouched. +### mplex: the multiplexer + +mplex has tmux's keyboard shortcuts: the default tables were dumped from tmux 3.6 itself, so `C-b c`, `C-b %`, `C-b "`, `C-b d`, `C-b [`, `C-b z`, `C-b w`, the copy-mode tables for both `emacs` and `vi` keys, the repeat keys, `M-1` to `M-7` layouts and the rest behave as in tmux, and so do the command names (`split-window`, `select-layout`, `list-keys`, ...), which you can also run from outside with `tai.py --mplex `. Windows, splits, zoom, rotate, scrollback with search and copy, paste buffers, several sessions and clients, choose-tree and a command prompt are implemented; the mouse, menus and popups are not (they are refused with a message, never ignored). + +Everything is configurable in **`~/.tai/mplex.toml`**: the main key (`prefix = "C-a"`), a second prefix, any option (`base-index`, `mode-keys`, `status-left`, colours, `history-limit`, ...) and any binding in any key table, including unbinding defaults: + +```toml +[options] +prefix = "C-a" +mode-keys = "vi" +base-index = 1 + +[keys.prefix] +"|" = "split-window -h" +"-" = "split-window -v" +"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. 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/research/INDEX.md b/research/INDEX.md index f12d684..8bf2d15 100644 --- a/research/INDEX.md +++ b/research/INDEX.md @@ -7,12 +7,12 @@ | Metric | Value | |---|---| | Subjects | 7 | -| Total source documents | 526 | -| — Tier 1 (Source Dossier, independently analyzed) | 153 | +| Total source documents | 527 | +| — Tier 1 (Source Dossier, independently analyzed) | 154 | | — Tier 2 (Source Citation Card, listed only) | 373 | -| Total conclusion documents | 14 | +| Total conclusion documents | 15 | | Archive opened | 2026-10-09 | -| Last updated | 2026-10-11 | +| Last updated | 2026-10-12 | ## Subjects @@ -21,7 +21,7 @@ | [`sqlite-rag-hybrid-search`](sqlite-rag-hybrid-search/) | 32 (10 / 22) | 1 | 2026-10-09 | 2026-10-09 | Designing tmux-log export + chunked retrieval; settled on BM25-only, no vector DB | | [`agent-memory-recency-tracking`](agent-memory-recency-tracking/) | 63 (16 / 47) | 1 | 2026-10-09 | 2026-10-09 | Designing an LRU-style last-used/use-count tracker for vault records and chunks | | [`async-pty-process-testing`](async-pty-process-testing/) | 59 (20 / 39) | 1 | 2026-10-09 | 2026-10-09 | Designing tai_test.py (pty vs. pipes, non-blocking stdin, process-group cleanup, test-framework shape) — backfilled after it was initially skipped, see activity log | -| [`shell-multiplexer-auto-attach`](shell-multiplexer-auto-attach/) | 138 (48 / 90) | 2 | 2026-10-09 | 2026-10-10 | Designing a tai-managed terminal multiplexer + bashrc auto-attach: tmux-as-substrate recommendation, interactive-vs-automation shell detection; extended 2026-10-10 to cover zsh/fish hook syntax | +| [`shell-multiplexer-auto-attach`](shell-multiplexer-auto-attach/) | 139 (49 / 90) | 3 | 2026-10-09 | 2026-10-12 | Designing a tai-managed terminal multiplexer + bashrc auto-attach: tmux-as-substrate recommendation, interactive-vs-automation shell detection; extended 2026-10-10 to cover zsh/fish hook syntax; extended 2026-10-12 with the tmux 3.6 key table, option and command dumps (installed binary) and the conclusion to build a native tmux-parity multiplexer configured by ~/.tai/mplex.toml | | [`tui-framework-design`](tui-framework-design/) | 94 (49 / 45) | 5 | 2026-10-10 | 2026-10-11 | Designing tai's in-house TUI framework (retained DOM, cuts/chops compositor, SGR mouse, bubbling events, VT sub-terminals); extended 2026-10-10 with synchronized-output verification (DEC 2026); implemented in working-tree tai.py and verified the same session; same-day follow-up fixed the 19 failures natively, deleted tai_pkg/, suite 320/320 green, committed; extended again for kitty keyboard protocol (double-Shift prompt dialog); extended 2026-10-11 with messaging-layout comparison (telecli, send-rs, za-tui, bubbletea examples) and palette-role theming survey (Textual, lipgloss, bubbletint, ratatui-themes, ratatui-kit, pytermgui) plus 2 conclusions, nothing built yet; extended again 2026-10-11 with Telegram look-and-feel survey (tdesktop day/night themes, lang strings, Android dialog/message cells, FAQ, blog, Bot API/features, WebApps ThemeParams, mini-apps SDK docs, iOS teardown — 16 Tier 1) plus 2 conclusions, nothing built yet | | [`agentic-tool-loop-design`](agentic-tool-loop-design/) | 74 (7 / 67) | 2 | 2026-10-11 | 2026-10-11 | Fixing stop-after-narration on prompt-JSON CLI backends without intent regexes; decided structural loop control plus exactly-once JSON-contract repair plus prompt contract, implemented in tai.py the same session (suite 357/357 + 16/16 green); approval tiers judged already industry-standard, no change | | [`muse-responses-openai-transpiler`](muse-responses-openai-transpiler/) | 66 (3 / 63) | 2 | 2026-10-11 | 2026-10-11 | Documenting Meta muse Responses wire format vs classic Chat Completions; decided bidirectional transpile is feasible with a fixed loss table and system guidance has three channels; scratch transpiler tested, nothing merged | @@ -46,6 +46,7 @@ | 2026-10-11 | `tui-framework-design` | Extended with 23 more sources (all Tier 1, no Tier 2 — every consulted source body was opened via web_fetch by the researching agent) + 2 conclusions (`conclusions/messaging-layout-chat-scene.md`, `conclusions/palette-role-theming.md`): 14 dossiers comparing 4 messaging-client implementations (telecli tui.rs; send-rs tui mod/state/config/status/chat-widget; za-tui tcss/main-screen/status/input/chat-view; bubbletea chat + glamour examples; ai-nexus ratatui guidance doc) and 9 dossiers surveying theme systems (Textual theme.py, design guide, constants.py; lipgloss pkg docs; bubbletint, ratatui-themes, ratatui-kit; pytermgui styling + file_loaders). Decisions: 30/70 chat scene with growing input + 1-line status, manual sidebar toggle first with sub-80 auto-hide deferred, modal keys, left-aligned grouped rows, markdown width-minus-chrome, pin-to-bottom streaming; 12-role palette with THEMES dict registry, --theme flag > TAI_THEME > default, set_theme + F4 cycle, resolve-at-draw with raw-color escape hatch, file overrides deferred. A same-session internal tai.py inventory (no chat-transcript widget, no theme object, hardcoded inline styles) informed both conclusions but produced no source documents (internal code, not external research). Nothing built yet — both conclusions are specified-not-shipped. Carried gaps: no shipped client implements auto breakpoints (80-col rule is guidance-doc only); no streaming/tool-call rendering observed (inferred); fixed-cell sidebars appear only in guidance docs; ratatui core styling, pytermgui NO_COLOR, Textual get_css_variables, and lipgloss v2 LightDark unverified (snippet or inference only). Subject total now 78 sources (33 Tier 1 / 45 Tier 2), 3 conclusions. Counts verified directly against the written files (per-subject grep; global grep also matches README.md's own template rows, excluded). | | 2026-10-11 | `tui-framework-design` | Extended with 16 more sources (all Tier 1, no Tier 2 — every consulted source body was fetched by the researching agents) + 2 conclusions (`conclusions/telegram-chat-grammar-adoption.md`, `conclusions/telegram-theme-roles-mapping.md`): 10 dossiers on Telegram chat look-and-feel (tdesktop day/night theme files, lang.strings copy sheet, Android DialogCell/ChatMessageCell, FAQ check semantics, chat-themes blog, third-party checkmarks explainer, Bot API keyboards/commands, bot features onboarding) and 6 on Mini App theming (official WebApps ThemeParams/events/design rules; SDK docs for main/back, settings, viewport, theming; third-party iOS teardown). Decisions: adopt Telegram's metadata grammar as rows (clock/checks/blue-checks/eye, no delivered state), 8-hue peer palette, service/date pills, literal status/composer/bot copy, and reply-vs-inline keyboard grammar, rejecting bubbles, gradient themes, and pixel geometry; map the 15 ThemeParams keys onto tai's 12 roles with Desktop-file plus dark-sample defaults and a never-hardcode/re-read-on-switch discipline. A same-session internal tai.py inventory (chat scene present but plain: no ticks/dividers/header/rich rows/composer buttons; 6 of 12 roles used, hardcoded borders) informed both conclusions with per-item patch targets but produced no source documents (internal code, not external research). Nothing built yet — both conclusions are specified-not-shipped. Carried gaps: iOS/Web hexes unverified (Desktop day/night only); bubble radii/paddings/spacing/tail geometry, header icon inventory, chat-list time/date formatting and truncation, send-vs-voice swap and media-bubble time overlay unknown; no official typography/spacing spec (teardown values only); light defaults for section_*/subtitle/destructive unpublished; WebViewColors diagram uninspected; back-button SDK page yielded no captured evidence. Subject total now 94 sources (49 Tier 1 / 45 Tier 2), 5 conclusions. Counts verified directly against the written files via `research/audit.py` (PASS). | | 2026-10-11 | `muse-responses-openai-transpiler` | New subject: 66 sources (3 Tier 1 / 63 Tier 2) + 2 conclusions, covering the Meta muse Responses wire format vs classic Chat Completions (official migration guide, Responses reference, Chat reference fetched and synthesized; 63 search listings logged as seen-not-analyzed). Decisions: bidirectional transpile is feasible with a fixed warn-on-drop loss table, and system guidance has three channels (instructions preferred, developer/system Items otherwise). Backed by a scratch stdlib transpiler (`/tmp/openai_transpile.py`, self-test green, real-capture round trip verified); nothing merged into tai.py. Counts verified directly against the written files via `research/audit.py` (PASS). | +| 2026-10-12 | `shell-multiplexer-auto-attach` | Extended with 1 source (Tier 1: output of the installed tmux 3.6, `list-keys`, `list-keys -N`, `show-options`, `list-commands`) + 1 conclusion (`conclusions/tmux-parity-native-multiplexer.md`): the native multiplexer gets tmux's default key tables, command and option names, configured by `~/.tai/mplex.toml`; the earlier "build on tmux" recommendation stays for the tmux controller and detection rules. Behavior was also compared command by command against the same binary (layouts, windows, copy mode), see `mplex.md`. | ## How to use this index diff --git a/research/shell-multiplexer-auto-attach/conclusions/multiplexer-proposal-design-basis.md b/research/shell-multiplexer-auto-attach/conclusions/multiplexer-proposal-design-basis.md index 6aa92ec..4cd9638 100644 --- a/research/shell-multiplexer-auto-attach/conclusions/multiplexer-proposal-design-basis.md +++ b/research/shell-multiplexer-auto-attach/conclusions/multiplexer-proposal-design-basis.md @@ -10,7 +10,7 @@ | Tier | N/A | | Date decided | 2026-10-09 | | Decided by | retoor, advised by tai (agent, Claude Sonnet 5) | -| Status | Active (research complete, proposal pending decision, no code written) | +| Status | Active for the tmux-backed controller and the shell-detection rules; the rejection of a from-scratch multiplexer was reversed for the native backend by [tmux-parity-native-multiplexer.md](tmux-parity-native-multiplexer.md) (2026-10-12) | | Confidence | High | ## Question / problem statement @@ -53,3 +53,4 @@ None yet. This was a research-and-proposal task; implementation is explicitly de | Date | Change | |---|---| | 2026-10-09 | Initial conclusion recorded. | +| 2026-10-12 | Status note: the "from-scratch multiplexer" alternative was rejected here, then taken for the native backend on explicit request; see `tmux-parity-native-multiplexer.md`. The detection rules and the tmux controller remain in force. | diff --git a/research/shell-multiplexer-auto-attach/conclusions/tmux-parity-native-multiplexer.md b/research/shell-multiplexer-auto-attach/conclusions/tmux-parity-native-multiplexer.md new file mode 100644 index 0000000..145e69e --- /dev/null +++ b/research/shell-multiplexer-auto-attach/conclusions/tmux-parity-native-multiplexer.md @@ -0,0 +1,53 @@ +# Conclusion: A native multiplexer with tmux's key tables, configured by ~/.tai/mplex.toml + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | shell-multiplexer-auto-attach | +| Document type | Conclusion | +| Tier | N/A | +| Date decided | 2026-10-12 | +| Decided by | retoor, advised by tai (agent, Claude Sonnet 5) | +| Status | Active | +| Confidence | Medium | + +## Question / problem statement + +The native wrapper that had been built (`NativePlexerBackend`) was a single-pty byte relay with one shortcut (`Ctrl+A q`, kill). retoor asked for the multiplexer to have exactly the same features as tmux regarding shortcuts, with the main key and the others switchable through a config file, `~/.tai/mplex.toml`. Shortcuts like split, windows, detach and copy mode need panes, windows, a screen model, a persistent server and a client, so the question was how to get there and what to match. + +## Conclusion + +Keep the multiplexer native and make it a real tmux-shaped system: one detached server process per `TAI_HOME` owning every pty, window layout, scrollback and session; thin clients that attach over a Unix socket and receive finished frames. Key tables, command names, command flags and option names follow tmux 3.6, taken from the installed binary's own dumps rather than from memory, and are embedded as data. The default prefix is `C-b`, as in tmux. `~/.tai/mplex.toml` (TOML, because it is stdlib on Python 3.11+ and unambiguous) holds `[options]` and `[keys.]`; bad entries are skipped loudly, counted, announced on the status line and listed by `show-messages`. Not implemented, and refused with a clear message instead of silently ignored: mouse, `display-menu`, `customize-mode`, `display-popup`, hooks, `pipe-pane`, `join-pane`/`move-pane`, `link-window`, `respawn-*`, `wait-for`, lock commands. + +## Supporting sources + +- `local-tmux-3-6-keybinding-and-option-dumps.md`: the exact default tables, notes, options and command list that were embedded. +- `man7-tmux-manual.md`, `tao-of-tmux-server-internals.md`: the client/server split and the command model that were copied structurally. +- `stalkr-remote-tty-resize-design.md`, `systemd-ptyfwd-resize-forwarding.md`, `lkml-2003-sigwinch-foreground-process-group.md`: resize forwarding and pty handling that the server now does itself. + +## Alternatives considered and rejected + +- **Build on tmux as a substrate** (the recommendation of `multiplexer-proposal-design-basis.md`). Not taken for the multiplexer itself: the request was for tai's own multiplexer with its own config file and key semantics, and it keeps tai a single stdlib-only file with no external binary. The tmux backend remains for terminals already inside tmux (`TmuxPlexerController`). +- **Keep the transparent relay and add a few shortcuts.** Rejected: windows, panes, scrollback and detach all need a screen model and a persistent owner of the ptys. +- **Reproduce tmux's key tables from memory.** Rejected after the installed binary turned out to be available; memory would have missed details (the repeat flags, the copy-mode tables, the notes). +- **Support tmux.conf syntax as the config.** Rejected: the request was explicitly for a similar but differently named file, and TOML gives validation errors with a location. + +## Risks / open questions + +- Behavior was compared with tmux 3.6 for layouts, windows and copy mode (see `mplex.md`, "Checked against tmux 3.6"); the listed remaining differences (wrapped lines, bracket fallback, mouse) are open. +- No reflow on resize and wide characters count as one column (limits of `TuiVtScreen`). +- The server is a long-lived background process; a crash loses every pane. Mitigation so far: one broad handler around command execution and the control socket, tracebacks in `plexer/server.log`. +- Revisit if tmux changes its default tables, or if mouse support is requested. + +## Action taken + +Implemented in `tai.py` (the `Mplex*` classes, `MplexCli`, `NativePlexerBackend` as a thin attach), documented in `mplex.md`, covered by unit tests in `test_tai.py` and integration tests in `tests/mplex.py` and `tests/plexer.py`. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-12 | Initial conclusion recorded. | +| 2026-10-12 | Risks corrected after the tmux comparison runs: layouts, window ordering and copy mode were compared command by command, and several defaults (window height for detached sessions, remainder cell placement, selection rules, search placement) were changed to match. | diff --git a/research/shell-multiplexer-auto-attach/local-tmux-3-6-keybinding-and-option-dumps.md b/research/shell-multiplexer-auto-attach/local-tmux-3-6-keybinding-and-option-dumps.md new file mode 100644 index 0000000..36f22ce --- /dev/null +++ b/research/shell-multiplexer-auto-attach/local-tmux-3-6-keybinding-and-option-dumps.md @@ -0,0 +1,54 @@ +# tmux 3.6 default key tables, options and commands (dumped from the installed binary) + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | shell-multiplexer-auto-attach | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | Output of `tmux 3.6` (`/usr/bin/tmux` on the development machine) | +| Source URL | local: `tmux -L probe -f /dev/null start-server \; list-keys` (also `list-keys -N`, `show-options -g`, `show-options -gw`, `show-options -gs`, `list-commands`; `-L probe` is a throwaway socket, `-f /dev/null` ignores any user config) | +| Source publisher / author | tmux project (binary as packaged by the OS distribution) | +| Source type | other | +| Source date (published) | Unknown — not stated | +| Date researched | 2026-10-12 | +| Researched by | tai (agent), model: Claude Sonnet 5, on behalf of retoor | +| Research method | direct command output (the primary source: the program whose behavior is being matched) | +| Confidence | High | +| Status | Active | + +## Summary + +The requirement was "exactly the same features as tmux regarding shortcuts". The installed tmux 3.6 was asked for its own default tables instead of reproducing them from memory or from the man page. The dump is the authoritative statement of what tmux binds by default, with the exact command text of each binding, which keys are repeatable, and the one-line notes `list-keys -N` shows. + +## Key findings + +- `list-keys` on a config-less server prints 267 bindings: 87 in `prefix`, 67 in `copy-mode`, 80 in `copy-mode-vi`, and 33 mouse bindings (19 in `root`, 7 in each copy-mode table). There are no keyboard bindings in `root` by default. +- 234 of those are keyboard bindings and are the set embedded in `tai.py` as `MPLEX_DEFAULT_KEYS`. Mouse bindings are left out because the multiplexer has no mouse support. +- Repeatable (`-r`) prefix bindings: `Up/Down/Left/Right` (select-pane), `C-Up/C-Down/C-Left/C-Right` (resize 1), `M-Up/M-Down/M-Left/M-Right` (resize 5), `S-Up/S-Down/S-Left/S-Right` (refresh-client panning), `DC` (refresh-client -c). +- Binding commands use the 3.x brace-block syntax: `command-prompt -I "#S" { rename-session "%%" }`, `confirm-before -p "kill-pane #P? (y/n)" kill-pane`, `command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }`. Copy-mode keys are all `send-keys -X `; prompts that feed them use `command-prompt -1`, `-N`, `-i`, `-T search`. +- `<` and `>` open `display-menu` with long mouse-oriented menus; `C` opens `customize-mode`; `M` is `select-pane -M`, `m` is `select-pane -m`. +- `list-keys -N` yields 87 note lines for the prefix table, the text shown by `prefix ?`. +- Defaults that differ from what older documentation says: `escape-time` is 10, `default-terminal` is `tmux-256color`, `history-limit` 2000, `repeat-time` 500, `display-time` 750, `display-panes-time` 1000, `status-right` is `"#{=21:pane_title}" %H:%M %d-%b-%y`, `status-left-length` 10, `status-right-length` 40, `word-separators` is `!"#$%&'()*+,-./:;<=>?@[\]^`{|}~`, `main-pane-width` 80, `main-pane-height` 24, `mode-keys`/`status-keys` are `emacs` (and `vi` when `$VISUAL`/`$EDITOR` contains "vi"). +- `list-commands` lists 90 commands with their exact flag strings, used as the getopt specs in `MPLEX_COMMAND_SPECS`. + +## Pros / strengths + +First-hand and exact: matches the real program's behavior, not an interpretation of it. Reproducible with one command. Includes details documentation omits (the full copy-mode tables, the exact command text, which bindings repeat). + +## Cons / limitations / caveats + +Version-specific: tmux 3.6 only; other versions differ (older ones lack brace blocks, `M-6`/`M-7` layouts, `customize-mode`). Reflects the distribution's build, not necessarily upstream defaults. The dumps say what is bound, not how each command behaves. Behavior was therefore compared separately, by running the same command sequences against this tmux (detached, and attached through a pty where a client is needed for selections) and against mplex: pane geometry for all layouts, splits, resizes, rotation and selection; window creation, closing and ordering; and about 840 recorded copy mode cursor, scroll and clipboard results (both key modes) plus differential fuzzing of random key sequences. Remaining known differences are listed in `mplex.md`. + +## Relevance to tai + +Settles the key tables, command names, option names and defaults of the `mplex` multiplexer without guessing. The embedded table is regression-protected by unit tests that parse every default binding and check that every referenced command and copy-mode action exists. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-12 | Initial dossier created. | +| 2026-10-12 | Cons section corrected to describe the command-behavior comparisons that were actually run against tmux 3.6 after the first draft. | diff --git a/tai.py b/tai.py index 5b5a2b1..ef13f29 100755 --- a/tai.py +++ b/tai.py @@ -5924,7 +5924,7 @@ def create_skill_prompt(name, brief, skill_dir): PLEXER_DIR_NAME = "plexer" -PLEXER_PREFIX_KEY = b"\x01" # Ctrl+A, deliberately distinct from tmux's default Ctrl+B +PLEXER_PANE_ENV = "TAI_PLEXER_PANE" PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET" PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE" PLEXER_RECV_MAX = 1 << 20 @@ -5976,10 +5976,13 @@ class PlexerController: class NativePlexerController(PlexerController): kind = "native" - def __init__(self, socket_path): + def __init__(self, socket_path, pane=None): self.socket_path = socket_path + self.pane = pane def _call(self, payload): + if self.pane: + payload = dict(payload, pane=self.pane) try: conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) conn.settimeout(10) @@ -6064,7 +6067,7 @@ class PlexerControllerFactory: def detect(): socket_path = os.environ.get(PLEXER_SOCKET_ENV) if socket_path: - return NativePlexerController(socket_path) + return NativePlexerController(socket_path, os.environ.get(PLEXER_PANE_ENV)) pane = os.environ.get("TMUX_PANE") if pane: return TmuxPlexerController(pane) @@ -6072,11 +6075,11 @@ class PlexerControllerFactory: # --------------------------------------------------------------------------- # -# Backend side: only NativePlexerBackend actually exists, because tmux -# already IS a backend when it's active — there is nothing for tai to wrap -# in that case (see PlexerControllerFactory). PlexerBackend is kept as the -# extension point for a future alternative (e.g. a screen-based backend) -# without touching the controller side or the detection flow above. +# Backend side: PlexerBackend is the extension point the shell hook execs into. +# NativePlexerBackend (bottom of the Mplex block) starts or joins the mplex +# server and attaches this terminal to a fresh session. tmux itself needs no +# backend: when it is active there is nothing for tai to wrap (see +# PlexerControllerFactory). # --------------------------------------------------------------------------- # class PlexerBackend: @@ -6084,187 +6087,1775 @@ class PlexerBackend: raise NotImplementedError -class NativePlexerBackend(PlexerBackend): - """Owns the pty directly: forks the user's real shell onto one, relays - bytes between the real terminal and that pty in both directions, forwards - SIGWINCH as a resize, tees everything to a log file, and serves control - commands (send/read/status) over a Unix socket so a separate tai process - (the agent the user is actually talking to) can drive this session. +# --------------------------------------------------------------------------- # +# Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns +# every pty, window layout and scrollback; clients attach over a Unix socket +# and receive finished frames. Key bindings, command names and option names +# follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`), +# and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md. +# --------------------------------------------------------------------------- # - One prefix key (Ctrl+A, PLEXER_PREFIX_KEY) is reserved for tai's own - shortcuts in the raw keystroke stream. There is deliberately no - tmux-compatible keymap here: when tmux is the active backend, tai never - owns the raw keystroke stream at all (see TmuxPlexerController), so there - is nothing to avoid colliding with. +MPLEX_SOCKET_NAME = "server.sock" +MPLEX_LOCK_NAME = "server.lock" +MPLEX_LOG_NAME = "server.log" +MPLEX_CONFIG_NAME = "mplex.toml" +MPLEX_MAGIC = b"\x01" +MPLEX_FRAME_MAX = 1 << 22 +MPLEX_CLIENT_OUT_MAX = 1 << 22 +MPLEX_PANE_IN_MAX = 1 << 22 +MPLEX_MAX_SESSIONS = 64 +MPLEX_MAX_WINDOWS = 256 +MPLEX_MAX_PANES = 256 +MPLEX_MAX_CLIENTS = 32 +MPLEX_MESSAGES_MAX = 200 +MPLEX_HISTORY_CAP = 200000 +MPLEX_BUFFERS_CAP = 100 +MPLEX_PROMPT_HISTORY_CAP = 100 +MPLEX_COMMAND_DEPTH_MAX = 16 +MPLEX_SHELL_TIMEOUT = 5.0 +MPLEX_SHELL_OUTPUT_MAX = 65536 +MPLEX_PASTE_MAX = 1 << 22 +MPLEX_RENDER_INTERVAL = 0.012 +MPLEX_REAP_KILL_AFTER = 2.0 +MPLEX_IDLE_EXIT = 15.0 +MPLEX_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") - A characteristic worth knowing, not a bug: this relay loop is single- - threaded and synchronous, so its own os.write(stdout_fd, ...) applies - normal pty backpressure -- if whatever is reading the OTHER end of the - outer pty (normally a real terminal emulator, which always reads - continuously) ever stops draining for a stretch, a large enough burst of - child output can block that write and stall the whole loop until the - reader catches up. This is standard for any synchronous pty relay - (tmux, ssh, `script` all have the same property) and is why writes - headed INTO the child are queued and drained cooperatively rather than - written directly (see _pending_to_child) -- that fix was for a - self-inflicted deadlock on the inner side; this is normal, external - backpressure on the outer side, not something to engineer around. - """ +# (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings. +MPLEX_DEFAULT_KEYS = ( + ('copy-mode', 'Escape', 'send-keys -X cancel', False, ''), + ('copy-mode', 'Space', 'send-keys -X page-down', False, ''), + ('copy-mode', ',', 'send-keys -X jump-reverse', False, ''), + ('copy-mode', ';', 'send-keys -X jump-again', False, ''), + ('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), + ('copy-mode', 'N', 'send-keys -X search-reverse', False, ''), + ('copy-mode', 'P', 'send-keys -X toggle-position', False, ''), + ('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''), + ('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), + ('copy-mode', 'X', 'send-keys -X set-mark', False, ''), + ('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), + ('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), + ('copy-mode', 'n', 'send-keys -X search-again', False, ''), + ('copy-mode', 'q', 'send-keys -X cancel', False, ''), + ('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''), + ('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), + ('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''), + ('copy-mode', 'End', 'send-keys -X end-of-line', False, ''), + ('copy-mode', 'NPage', 'send-keys -X page-down', False, ''), + ('copy-mode', 'PPage', 'send-keys -X page-up', False, ''), + ('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''), + ('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''), + ('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''), + ('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''), + ('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-<', 'send-keys -X history-top', False, ''), + ('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''), + ('copy-mode', 'M-R', 'send-keys -X top-line', False, ''), + ('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''), + ('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''), + ('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''), + ('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''), + ('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''), + ('copy-mode', 'M-v', 'send-keys -X page-up', False, ''), + ('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''), + ('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''), + ('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''), + ('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''), + ('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''), + ('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''), + ('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''), + ('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''), + ('copy-mode', 'C-c', 'send-keys -X cancel', False, ''), + ('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''), + ('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''), + ('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''), + ('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), + ('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''), + ('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''), + ('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''), + ('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''), + ('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''), + ('copy-mode', 'C-v', 'send-keys -X page-down', False, ''), + ('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''), + ('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''), + ('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''), + ('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''), + ('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''), + ('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''), + ('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''), + ('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''), + ('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''), + ('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''), + ('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''), + ('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''), + ('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''), + ('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), + ('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''), + ('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''), + ('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''), + ('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''), + ('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), + ('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''), + ('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), + ('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''), + ('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''), + ('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''), + ('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''), + ('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''), + ('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''), + ('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''), + ('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''), + ('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), + ('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''), + ('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''), + ('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''), + ('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''), + ('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''), + ('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''), + ('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), + ('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''), + ('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''), + ('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''), + ('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''), + ('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''), + ('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''), + ('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''), + ('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''), + ('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), + ('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''), + ('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''), + ('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''), + ('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''), + ('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''), + ('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''), + ('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''), + ('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''), + ('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''), + ('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''), + ('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''), + ('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''), + ('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''), + ('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''), + ('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''), + ('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''), + ('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''), + ('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''), + ('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''), + ('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''), + ('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''), + ('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''), + ('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''), + ('prefix', 'Space', 'next-layout', False, 'Select next layout'), + ('prefix', '!', 'break-pane', False, 'Break pane to a new window'), + ('prefix', '"', 'split-window', False, 'Split window vertically'), + ('prefix', '#', 'list-buffers', False, 'List all paste buffers'), + ('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'), + ('prefix', '%', 'split-window -h', False, 'Split window horizontally'), + ('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'), + ('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'), + ('prefix', '(', 'switch-client -p', False, 'Switch to previous client'), + ('prefix', ')', 'switch-client -n', False, 'Switch to next client'), + ('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'), + ('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'), + ('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'), + ('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'), + ('prefix', '0', 'select-window -t :=0', False, 'Select window 0'), + ('prefix', '1', 'select-window -t :=1', False, 'Select window 1'), + ('prefix', '2', 'select-window -t :=2', False, 'Select window 2'), + ('prefix', '3', 'select-window -t :=3', False, 'Select window 3'), + ('prefix', '4', 'select-window -t :=4', False, 'Select window 4'), + ('prefix', '5', 'select-window -t :=5', False, 'Select window 5'), + ('prefix', '6', 'select-window -t :=6', False, 'Select window 6'), + ('prefix', '7', 'select-window -t :=7', False, 'Select window 7'), + ('prefix', '8', 'select-window -t :=8', False, 'Select window 8'), + ('prefix', '9', 'select-window -t :=9', False, 'Select window 9'), + ('prefix', ':', 'command-prompt', False, 'Prompt for a command'), + ('prefix', ';', 'last-pane', False, 'Move to the previously active pane'), + ('prefix', '<', 'display-menu', False, 'Display window menu'), + ('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'), + ('prefix', '>', 'display-menu', False, 'Display pane menu'), + ('prefix', '?', 'list-keys -N', False, 'List key bindings'), + ('prefix', 'C', 'customize-mode -Z', False, 'Customize options'), + ('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'), + ('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'), + ('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'), + ('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'), + ('prefix', '[', 'copy-mode', False, 'Enter copy mode'), + ('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'), + ('prefix', 'c', 'new-window', False, 'Create a new window'), + ('prefix', 'd', 'detach-client', False, 'Detach the current client'), + ('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'), + ('prefix', 'i', 'display-message', False, 'Display window information'), + ('prefix', 'l', 'last-window', False, 'Select the previously current window'), + ('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'), + ('prefix', 'n', 'next-window', False, 'Select the next window'), + ('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'), + ('prefix', 'p', 'previous-window', False, 'Select the previous window'), + ('prefix', 'q', 'display-panes', False, 'Display pane numbers'), + ('prefix', 'r', 'refresh-client', False, 'Redraw the current client'), + ('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'), + ('prefix', 't', 'clock-mode', False, 'Show a clock'), + ('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'), + ('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'), + ('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'), + ('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'), + ('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'), + ('prefix', '~', 'show-messages', False, 'Show messages'), + ('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'), + ('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'), + ('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'), + ('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'), + ('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'), + ('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'), + ('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'), + ('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'), + ('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'), + ('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'), + ('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'), + ('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'), + ('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'), + ('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'), + ('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'), + ('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'), + ('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'), + ('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'), + ('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'), + ('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'), + ('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'), + ('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'), + ('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'), + ('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'), + ('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'), + ('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'), + ('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'), + ('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'), + ('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'), + ('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'), + ('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'), +) - def __init__(self, home, profile, shell_cmd=None): - self.home = home - self.profile = profile - self.shell_cmd = shell_cmd or [os.environ.get("SHELL") or "/bin/bash"] - self.session_id = "%s-%d" % (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S"), os.getpid()) - self.socket_path = os.path.join(plexer_dir(home), self.session_id + ".sock") - self.log_path = os.path.join(plexer_dir(home), self.session_id + ".log") - self.pid = None - self.master_fd = None - self._log_fh = None - self._server = None - self._saved_termios = None - self._resize_pending = False - self._running = False - self._pending_to_child = bytearray() +MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled") - def _set_raw(self): - if termios is None: - return - fd = sys.stdin.fileno() - try: - self._saved_termios = termios.tcgetattr(fd) - tty.setraw(fd) - except (termios.error, OSError, ValueError): - self._saved_termios = None - def _restore_tty(self): - if termios is None or self._saved_termios is None: - return - try: - termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._saved_termios) - except (termios.error, OSError): - pass +class MplexError(Exception): + """A user-facing failure: the text is shown verbatim as the command's error.""" - def _get_winsize(self, fd): - try: - packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\0" * 8) - except OSError: - return None - rows, cols = struct.unpack("HH", packed[:4]) - return rows, cols - def _apply_winsize(self): - size = self._get_winsize(sys.stdin.fileno()) - if size is None or self.master_fd is None: - return - rows, cols = size - try: - fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) - except OSError: - pass +class MplexKeys: + """Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what + a terminal sends, and byte encoding for send-keys.""" - def _on_sigwinch(self, _signum, _frame): - self._resize_pending = True + ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC", + "Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"} + NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace", + "Escape", "Space") + tuple("F%d" % n for n in range(1, 13)) + FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End", + "pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab", + "enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"} + CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"} + CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24} + SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"} + CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F} - def _spawn_child(self): - pid, master_fd = pty.fork() - if pid == 0: - env = dict(os.environ) - env[PLEXER_ACTIVE_ENV] = "1" - env[PLEXER_SOCKET_ENV] = self.socket_path + @classmethod + def canonical(cls, spec): + if spec == " ": + return "Space" + text = str(spec).strip() + if not text: + raise MplexError("empty key name") + mods = set() + while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": + mods.add(text[0]) + text = text[2:] + if len(text) == 2 and text[0] == "^": + mods.add("C") + text = text[1] + text = cls.ALIASES.get(text, text) + if len(text) == 1: + if text == " ": + text = "Space" + elif "C" in mods and text.isalpha(): + text = text.lower() + elif "S" in mods and text.isalpha(): + text = text.upper() + mods.discard("S") + elif text not in cls.NAMED: + raise MplexError("unknown key: %s" % spec) + if "C" in mods: + if text == "i": + mods.discard("C") + text = "Tab" + elif text == "m": + mods.discard("C") + text = "Enter" + elif text == "[": + mods.discard("C") + text = "Escape" + return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text + + @classmethod + def name_of_char(cls, raw): + """Canonical name for one decoded character (no escape prefix).""" + if len(raw) == 1: + byte = raw[0] + if byte == 0x00: + return "C-Space" + if byte == 0x09: + return "Tab" + if byte == 0x0D: + return "Enter" + if byte == 0x1B: + return "Escape" + if byte == 0x20: + return "Space" + if byte == 0x7F: + return "BSpace" + if 0x01 <= byte <= 0x1A: + return "C-" + chr(byte + 0x60) + if 0x1C <= byte <= 0x1F: + return "C-" + "\\]^_"[byte - 0x1C] + return chr(byte) + return raw.decode("utf-8", "replace") + + @classmethod + def split(cls, data, final=False): + """bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys + (they are still forwarded). A lone ESC waits until final=True.""" + tokens = [] + buf = bytes(data) + while buf: + if buf[0] != 0x1B: + length = 1 + lead = buf[0] + if lead >= 0xF0: + length = 4 + elif lead >= 0xE0: + length = 3 + elif lead >= 0xC0: + length = 2 + if length > len(buf): + if not final: + break + length = len(buf) + raw = buf[:length] + tokens.append((cls.name_of_char(raw), raw)) + buf = buf[length:] + continue + if len(buf) == 1: + if not final: + break + tokens.append(("Escape", b"\x1b")) + buf = b"" + break + event, rest = tui_parse_one(buf, final) + if event == "need_more": + if not final: + break + tokens.append(("Escape", b"\x1b")) + buf = buf[1:] + continue + if event == "escape": + tokens.append(("Escape", b"\x1b")) + buf = buf[1:] + continue + consumed = len(buf) - len(rest) + if consumed <= 0: + consumed = 1 + raw = buf[:consumed] + buf = buf[consumed:] + tokens.append((cls._name_of_event(event, raw), raw)) + return tokens, buf + + @classmethod + def _name_of_event(cls, event, raw): + if not isinstance(event, TuiKey): + return "" + if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"): + inner = cls.name_of_char(raw[1:]) try: - os.execvpe(self.shell_cmd[0], self.shell_cmd, env) - except OSError as exc: - sys.stderr.write("tai plexer: failed to exec %s: %s\n" % (self.shell_cmd[0], exc)) + return cls.canonical("M-" + inner) + except MplexError: + return "" + key = event.key + base = key.split("+")[-1] if not key.endswith("+") else "+" + if base in cls.FROM_TUI: + base = cls.FROM_TUI[base] + elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit(): + base = "F" + base[1:] + else: + return "" + prefix = "" + mods = set(getattr(event, "modifiers", None) or []) + if "ctrl" in mods: + prefix += "C-" + if "alt" in mods or "meta" in mods: + prefix += "M-" + if "shift" in mods: + prefix += "S-" + try: + return cls.canonical(prefix + base) + except MplexError: + return "" + + @classmethod + def to_bytes(cls, name, app_cursor=False): + """Bytes a terminal would send for a canonical key name.""" + mods = "" + text = name + while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": + mods += text[0] + text = text[2:] + if len(text) == 1 or text == "Space": + char = " " if text == "Space" else text + data = char.encode("utf-8") + if "C" in mods: + if char == " ": + data = b"\x00" + elif char.isalpha() and char.isascii(): + data = bytes([ord(char.upper()) & 0x1F]) + elif char in cls.CTRL_PUNCT: + data = bytes([cls.CTRL_PUNCT[char]]) + return (b"\x1b" if "M" in mods else b"") + data + simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"} + if text in simple and not ("S" in mods and text not in ("BTab",)): + return (b"\x1b" if "M" in mods else b"") + simple[text] + mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0) + if text in cls.CSI_FINAL: + if mod_code > 1: + return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii") + return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii") + if text in cls.CSI_TILDE: + if mod_code > 1: + return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii") + return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii") + if text in cls.SS3_FUNC: + if mod_code > 1: + return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii") + return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii") + raise MplexError("unknown key: %s" % name) + + @classmethod + def app_cursor_form(cls, raw): + """Plain CSI cursor keys become SS3 when the pane asked for application cursor mode.""" + if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"): + return b"\x1bO" + raw[2:3] + return raw + + +class MplexArgs: + """getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value.""" + + def __init__(self, flags, positional): + self.flags = flags + self.positional = positional + + def has(self, flag): + return flag in self.flags + + def get(self, flag, default=None): + values = self.flags.get(flag) + if not values: + return default + return values[-1] + + def all(self, flag): + return list(self.flags.get(flag) or []) + + def int(self, flag, default=None): + value = self.get(flag) + if value is None or value is True: + return default + try: + return int(value) + except ValueError: + raise MplexError("-%s expects a number: %s" % (flag, value)) + + @classmethod + def parse(cls, name, spec, words, min_args=0, max_args=None): + takes = set() + known = set() + index = 0 + while index < len(spec): + known.add(spec[index]) + if index + 1 < len(spec) and spec[index + 1] == ":": + takes.add(spec[index]) + index += 1 + index += 1 + flags = {} + positional = [] + pos = 0 + while pos < len(words): + word = words[pos] + if word == "--": + positional.extend(words[pos + 1:]) + break + if len(word) < 2 or word[0] != "-": + positional.extend(words[pos:]) + break + inner = 1 + while inner < len(word): + letter = word[inner] + if letter not in known: + raise MplexError("command %s: unknown flag -%s" % (name, letter)) + if letter in takes: + if inner + 1 < len(word): + value = word[inner + 1:] + else: + pos += 1 + if pos >= len(words): + raise MplexError("command %s: -%s expects an argument" % (name, letter)) + value = words[pos] + flags.setdefault(letter, []).append(value) + break + flags.setdefault(letter, []).append(True) + inner += 1 + pos += 1 + if len(positional) < min_args or (max_args is not None and len(positional) > max_args): + raise MplexError("command %s: wrong number of arguments" % name) + return cls(flags, positional) + + +class MplexCommandParser: + """Splits tmux-syntax command text into commands of words: quotes, backslash, + {braces} as one argument, ';' and newlines between commands.""" + + @staticmethod + def parse(text): + commands = [] + words = [] + cur = [] + has = False + i = 0 + n = len(text) + + def end_word(): + nonlocal cur, has + if has: + words.append("".join(cur)) + cur = [] + has = False + + def end_command(): + nonlocal words + end_word() + if words: + commands.append(words) + words = [] + + while i < n: + c = text[i] + if c in " \t\r": + end_word() + i += 1 + elif c == "\n" or c == ";": + end_command() + i += 1 + elif c == "\\": + if i + 1 < n: + if text[i + 1] == "\n": + i += 2 + continue + cur.append(text[i + 1]) + has = True + i += 2 + elif c == "'": + j = text.find("'", i + 1) + if j < 0: + raise MplexError("unterminated single quote") + cur.append(text[i + 1:j]) + has = True + i = j + 1 + elif c == '"': + j = i + 1 + piece = [] + while j < n and text[j] != '"': + if text[j] == "\\" and j + 1 < n: + nxt = text[j + 1] + piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt)) + j += 2 + else: + piece.append(text[j]) + j += 1 + if j >= n: + raise MplexError("unterminated double quote") + cur.append("".join(piece)) + has = True + i = j + 1 + elif c == "{" and not has: + depth = 0 + j = i + quote = "" + while j < n: + ch = text[j] + if quote: + if ch == "\\" and quote == '"': + j += 1 + elif ch == quote: + quote = "" + elif ch in "'\"": + quote = ch + elif ch == "\\": + j += 1 + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + break + j += 1 + if depth != 0 or j >= n: + raise MplexError("unterminated { block") + cur.append(text[i + 1:j].strip()) + has = True + i = j + 1 + else: + cur.append(c) + has = True + i += 1 + end_command() + return commands + + @staticmethod + def quote(word): + word = str(word) + if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word): + return word + return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"' + + +class MplexStyle: + """tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell + format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes.""" + + NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white") + ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64, + "reverse": 8, "hidden": 128, "strikethrough": 16} + + @classmethod + def color(cls, text, background): + text = text.strip().lower() + if text in ("default", "terminal", ""): + return None + base = 40 if background else 30 + if text in cls.NAMES: + return str(base + cls.NAMES.index(text)) + if text.startswith("bright") and text[6:] in cls.NAMES: + return str(base + 60 + cls.NAMES.index(text[6:])) + if text.startswith("colour") or text.startswith("color"): + digits = text[6:] if text.startswith("colour") else text[5:] + if digits.isdigit() and int(digits) < 256: + return "%d;5;%d" % (48 if background else 38, int(digits)) + if text.startswith("#") and len(text) == 7: + try: + red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16) + except ValueError: + raise MplexError("bad colour: %s" % text) + return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue) + raise MplexError("bad colour: %s" % text) + + @classmethod + def parse(cls, text, base=(None, None, 0), reset=None): + """Apply a style string on top of base; "default" returns to reset (base when omitted).""" + fg, bg, attr = base + reset = base if reset is None else reset + for item in re.split(r"[,\s]+", str(text).strip()): + if not item: + continue + low = item.lower() + if low == "default": + fg, bg, attr = reset + elif low in ("none", "noattr"): + attr = 0 + elif low.startswith("fg="): + fg = cls.color(item[3:], False) + elif low.startswith("bg="): + bg = cls.color(item[3:], True) + elif low in cls.ATTRS: + attr |= cls.ATTRS[low] + elif low.startswith("no") and low[2:] in cls.ATTRS: + attr &= ~cls.ATTRS[low[2:]] + elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")): + continue + else: + raise MplexError("bad style: %s" % item) + return (fg, bg, attr) + + @staticmethod + def sgr(fg, bg, attr): + codes = ["0"] + for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")): + if attr & bit: + codes.append(code) + if fg: + codes.append(fg) + if bg: + codes.append(bg) + return "\x1b[" + ";".join(codes) + "m" + + +class MplexOptions: + """Typed option store with a parent chain: window, session, then global.""" + + WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~" + DEFS = { + "prefix": ("key", "C-b"), "prefix2": ("key", "None"), + "base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0), + "escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500), + "history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000), + "mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""), + "status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"), + "status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"), + "status-left": ("str", "[#{session_name}] "), + "status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"), + "status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40), + "status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"), + "message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"), + "mode-style": ("style", "noattr,bg=yellow,fg=black"), + "copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"), + "copy-mode-mark-style": ("style", "bg=red,fg=black"), + "display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000), + "display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"), + "window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), + "window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), + "window-status-separator": ("str", " "), + "window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"), + "pane-border-style": ("style", "default"), + "pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"), + "pane-border-lines": ("choice:single|double|heavy|simple", "single"), + "clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"), + "main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24), + "renumber-windows": ("bool", False), "automatic-rename": ("bool", True), + "automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"), + "allow-set-title": ("bool", True), + "default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"), + "detach-on-destroy": ("bool", True), "wrap-search": ("bool", True), + "word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False), + "synchronize-panes": ("bool", False), "copy-command": ("str", ""), + "buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50), + "set-clipboard": ("choice:on|external|off", "off"), + } + + def __init__(self, parent=None): + self.parent = parent + self.values = {} + + @classmethod + def check(cls, name, raw): + if name not in cls.DEFS: + raise MplexError("unknown or unsupported option: %s" % name) + kind = cls.DEFS[name][0] + text = str(raw) + if kind == "str": + return text + if kind == "bool": + low = text.strip().lower() + if low in ("on", "yes", "true", "1"): + return True + if low in ("off", "no", "false", "0"): + return False + raise MplexError("value is invalid: %s" % text) + if kind == "key": + return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text) + if kind == "colour": + MplexStyle.color(text, False) + return text + if kind == "style": + if "#{" not in text: + MplexStyle.parse(text) + return text + if kind.startswith("int:"): + _tag, low, high = kind.split(":") + try: + value = int(text) + except ValueError: + raise MplexError("value is invalid: %s" % text) + if value < int(low) or value > int(high): + raise MplexError("value out of range %s..%s: %s" % (low, high, text)) + return value + if kind.startswith("choice:"): + choices = kind.split(":", 1)[1].split("|") + if text not in choices: + raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text)) + return text + raise MplexError("bad option kind for %s" % name) + + def get(self, name): + node = self + while node is not None: + if name in node.values: + return node.values[name] + node = node.parent + default = self.DEFS[name][1] + if name in ("mode-keys", "status-keys"): + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" + return "vi" if "vi" in editor else "emacs" + return default + + def is_set(self, name): + return name in self.values + + def set(self, name, raw, append=False): + value = self.check(name, raw) + if append and isinstance(value, str) and name in self.values: + value = self.values[name] + value + self.values[name] = value + return value + + def toggle(self, name): + if self.DEFS.get(name, ("",))[0] != "bool": + raise MplexError("only boolean options can be toggled: %s" % name) + self.values[name] = not self.get(name) + + def unset(self, name): + if name not in self.DEFS: + raise MplexError("unknown or unsupported option: %s" % name) + self.values.pop(name, None) + + @staticmethod + def show(value): + if value is True: + return "on" + if value is False: + return "off" + return str(value) + + +class MplexBinding: + __slots__ = ("command", "repeat", "note") + + def __init__(self, command, repeat=False, note=""): + self.command = command + self.repeat = repeat + self.note = note + + +class MplexConfig: + """Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load: + each bad entry is skipped, counted in errors, and shown to the first client.""" + + ERRORS_MAX = 50 + TOP_KEYS = ("options", "keys") + + def __init__(self): + self.options = MplexOptions() + self.tables = {} + self.errors = [] + self.path = "" + self.skipped = 0 + for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS: + self.bind(table, MplexKeys.canonical(key), command, repeat, note) + + def error(self, text): + self.skipped += 1 + if len(self.errors) < self.ERRORS_MAX: + self.errors.append(text) + + def bind(self, table, key, command, repeat=False, note=""): + self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note) + + def unbind(self, table, key): + return self.tables.get(table, {}).pop(key, None) is not None + + def lookup(self, table, key): + return self.tables.get(table, {}).get(key) + + @staticmethod + def default_path(home): + return os.path.join(home, MPLEX_CONFIG_NAME) + + @classmethod + def load(cls, path): + config = cls() + config.path = path + config.apply_file(path) + return config + + def apply_file(self, path): + try: + if os.path.getsize(path) > MPLEX_CONFIG_MAX: + self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX)) + return + with open(path, "rb") as handle: + raw = handle.read() + except FileNotFoundError: + return + except OSError as exc: + self.error("%s: cannot read: %s" % (path, short_error(exc))) + return + try: + import tomllib + except ImportError: + self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path) + return + try: + data = tomllib.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + self.error("%s: not valid TOML: %s" % (path, exc)) + return + self.apply_data(data) + + def apply_data(self, data): + for key in data: + if key not in self.TOP_KEYS: + self.error("unknown top-level key [%s] (use options, keys)" % key) + options = data.get("options", {}) + if not isinstance(options, dict): + self.error("[options] must be a table") + options = {} + for name, value in options.items(): + try: + if isinstance(value, bool): + value = "on" if value else "off" + elif not isinstance(value, (str, int)): + raise MplexError("value must be a string, number or boolean") + self.options.set(name, value) + except MplexError as exc: + self.error("options.%s: %s" % (name, exc)) + keys = data.get("keys", {}) + if not isinstance(keys, dict): + self.error("[keys] must be a table") + return + clear = keys.get("clear", []) + if not isinstance(clear, list): + self.error("keys.clear must be a list of table names") + clear = [] + for table in clear: + self.tables[str(table)] = {} + for table, entries in keys.items(): + if table == "clear": + continue + if not isinstance(entries, dict): + self.error("keys.%s must be a table of key = command" % table) + continue + for key, spec in entries.items(): + self._apply_binding(table, key, spec) + + def _apply_binding(self, table, key, spec): + where = "keys.%s.%s" % (table, key) + try: + name = MplexKeys.canonical(key) + if spec is False or spec == "": + self.unbind(table, name) + return + note = "" + repeat = False + if isinstance(spec, dict): + unknown = [item for item in spec if item not in ("command", "repeat", "note")] + if unknown: + raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0]) + command = spec.get("command") + repeat = bool(spec.get("repeat", False)) + note = str(spec.get("note", "")) + else: + command = spec + if not isinstance(command, str) or not command.strip(): + raise MplexError("command must be a non-empty string, or false to unbind") + for words in MplexCommandParser.parse(command): + MplexCommands.resolve(words[0]) + self.bind(table, name, command, repeat, note) + except MplexError as exc: + self.error("%s: %s" % (where, exc)) + + +class MplexFormat: + """tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as + =N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through.""" + + SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index", + "S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"} + DEPTH_MAX = 12 + + @staticmethod + def match_close(text, start, opener, closer): + """Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1.""" + depth = 1 + pos = start + 1 + while pos < len(text): + ch = text[pos] + if ch == "#" and pos + 1 < len(text): + if text[pos + 1] == opener: + depth += 1 + pos += 2 + continue + if ch == closer: + depth -= 1 + if depth == 0: + return pos + pos += 1 + return -1 + + @classmethod + def split_commas(cls, text): + parts = [] + cur = [] + depth = 0 + pos = 0 + while pos < len(text): + ch = text[pos] + if ch == "#" and pos + 1 < len(text): + nxt = text[pos + 1] + if nxt == "," and depth == 0: + cur.append("#,") + pos += 2 + continue + if nxt in "{(": + depth += 1 + cur.append(text[pos:pos + 2]) + pos += 2 + continue + cur.append(text[pos:pos + 2]) + pos += 2 + continue + if ch == "}" or ch == ")": + depth = max(0, depth - 1) + if ch == "," and depth == 0: + parts.append("".join(cur)) + cur = [] + else: + cur.append(ch) + pos += 1 + parts.append("".join(cur)) + return parts + + @classmethod + def expand(cls, text, resolve, depth=0): + if depth > cls.DEPTH_MAX or not text: + return "" + out = [] + pos = 0 + n = len(text) + while pos < n: + ch = text[pos] + if ch != "#" or pos + 1 >= n: + out.append(ch) + pos += 1 + continue + nxt = text[pos + 1] + if nxt == "#": + out.append("#") + pos += 2 + elif nxt == "{": + end = cls.match_close(text, pos + 1, "{", "}") + if end < 0: + out.append(ch) + pos += 1 + else: + out.append(cls.evaluate(text[pos + 2:end], resolve, depth)) + pos = end + 1 + elif nxt == "(": + end = cls.match_close(text, pos + 1, "(", ")") + if end < 0: + out.append(ch) + pos += 1 + else: + out.append(resolve("(" + text[pos + 2:end]) or "") + pos = end + 1 + elif nxt == "[": + end = text.find("]", pos + 2) + if end < 0: + out.append(ch) + pos += 1 + else: + out.append(text[pos:end + 1]) + pos = end + 1 + elif nxt in cls.SHORT: + out.append(resolve(cls.SHORT[nxt]) or "") + pos += 2 + elif nxt in ",}": + out.append(nxt) + pos += 2 + else: + out.append(ch) + pos += 1 + return "".join(out) + + @staticmethod + def truthy(value): + return value not in ("", "0", None) + + @classmethod + def value_of(cls, text, resolve, depth): + """A variable name or a nested format: its text value.""" + if "#" in text: + return cls.expand(text, resolve, depth + 1) + value = resolve(text) + return "" if value is None else str(value) + + @classmethod + def evaluate(cls, inner, resolve, depth): + if inner.startswith("?"): + parts = cls.split_commas(inner[1:]) + index = 0 + while index + 1 < len(parts): + cond = cls.value_of(parts[index], resolve, depth) + if cls.truthy(cond): + return cls.expand(parts[index + 1], resolve, depth + 1) + index += 2 + if index < len(parts): + return cls.expand(parts[index], resolve, depth + 1) + return "" + colon = cls.modifier_colon(inner) + if colon < 0: + value = resolve(inner) + return "" if value is None else str(value) + mods = inner[:colon] + rest = inner[colon + 1:] + return cls.apply_modifiers(mods, rest, resolve, depth) + + @staticmethod + def modifier_colon(inner): + """Index of the ':' that ends a modifier list, or -1 when inner is a plain name.""" + colon = inner.find(":") + if colon <= 0: + return -1 + head = inner[:colon] + if "#" in head or "{" in head or " " in head: + return -1 + return colon + + @classmethod + def apply_modifiers(cls, mods, rest, resolve, depth): + specs = [item for item in mods.split(";") if item] + text = None + for spec in specs: + if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"): + parts = cls.split_commas(rest) + left = cls.expand(parts[0], resolve, depth + 1) if parts else "" + right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" + return cls.compare(spec, left, right) + if spec in ("m", "m/r", "m/ri", "m/i"): + parts = cls.split_commas(rest) + pattern = cls.expand(parts[0], resolve, depth + 1) if parts else "" + subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" + flags = re.IGNORECASE if spec.endswith("i") else 0 + if spec.startswith("m/r"): + try: + return "1" if re.search(pattern, subject, flags) else "0" + except re.error: + return "0" + import fnmatch + return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0" + if spec == "l": + return rest + if text is None: + text = cls.value_of(rest, resolve, depth) + text = cls.apply_one(spec, text, resolve, depth) + return text or "" + + @classmethod + def apply_one(cls, spec, text, resolve, depth): + if spec == "E" or spec == "T": + return cls.expand(text, resolve, depth + 1) + if spec == "q": + return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text) + if spec == "t": + try: + return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text))) + except (ValueError, OverflowError, OSError): + return text + if spec == "n": + return str(len(text)) + if spec == "b": + return os.path.basename(text) + if spec == "d": + return os.path.dirname(text) + if spec == "!": + return "0" if cls.truthy(text) else "1" + if spec == "!!": + return "1" if cls.truthy(text) else "0" + if spec.startswith("="): + body = spec[1:] + marker = "" + if body.startswith("/"): + _blank, count, marker = (body.split("/", 2) + ["", ""])[:3] + body = count + try: + limit = int(body) + except ValueError: + return text + if limit >= 0 and len(text) > limit: + return text[:limit] + marker + if limit < 0 and len(text) > -limit: + return marker + text[limit:] + return text + if spec.startswith("s/"): + fields = spec.split("/") + if len(fields) >= 3: + try: + return re.sub(fields[1], fields[2], text) + except re.error: + return text + return text + + @staticmethod + def compare(op, left, right): + if op.startswith("e|"): + kind = op.split("|")[1] if "|" in op else "" + try: + a, b = float(left), float(right) + except ValueError: + return "0" + table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0, + "==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b} + if kind not in table: + return "0" + value = table[kind] + if isinstance(value, bool): + return "1" if value else "0" + return str(int(value)) if float(value).is_integer() else str(value) + if op == "==": + return "1" if left == right else "0" + if op == "!=": + return "1" if left != right else "0" + if op == "&&": + return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0" + if op == "||": + return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0" + try: + a, b = float(left), float(right) + except ValueError: + a, b = left, right + return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0" + + @staticmethod + def with_time(text, stamp=None): + if "%" not in text: + return text + try: + return time.strftime(text, time.localtime(stamp)) + except (ValueError, OverflowError): + return text + + @staticmethod + def strip_styles(text): + return re.sub(r"#\[[^\]]*\]", "", text) + + +class MplexLayoutNode: + __slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse") + + def __init__(self, kind, pane=None, w=1, h=1): + self.reverse = False + self.kind = kind + self.children = [] + self.parent = None + self.pane = pane + self.x = 0 + self.y = 0 + self.w = w + self.h = h + + def leaves(self): + if self.kind == "pane": + return [self] + found = [] + for child in self.children: + found.extend(child.leaves()) + return found + + def adopt(self, child): + child.parent = self + self.children.append(child) + return child + + +class MplexLayout: + """Split tree: a node is a pane or a container laid out side by side ("lr") or + stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are + rescaled proportionally when the window changes size.""" + + @staticmethod + def size_of(node, kind): + return node.w if kind == "lr" else node.h + + @staticmethod + def set_size(node, kind, value): + if kind == "lr": + node.w = value + else: + node.h = value + + @classmethod + def assign(cls, node, x, y, w, h): + node.x, node.y, node.w, node.h = x, y, w, h + if node.kind == "pane": + return + kids = node.children + count = len(kids) + kind = node.kind + total = (w if kind == "lr" else h) - (count - 1) + if total < count: + sizes = [1] * count + else: + sizes = [max(1, cls.size_of(kid, kind)) for kid in kids] + weight = sum(sizes) or count + if weight != total: + sizes = [max(1, int(round(size * total / weight))) for size in sizes] + diff = total - sum(sizes) + while diff != 0: + moved = False + for index in (range(count) if diff > 0 else range(count - 1, -1, -1)): + if diff > 0: + sizes[index] += 1 + diff -= 1 + moved = True + elif sizes[index] > 1: + sizes[index] -= 1 + diff += 1 + moved = True + if diff == 0: + break + if not moved: + break + order = list(zip(kids, sizes)) + if node.reverse: + order.reverse() + pos = x if kind == "lr" else y + for kid, size in order: + if kind == "lr": + cls.assign(kid, pos, y, size, h) + else: + cls.assign(kid, x, pos, w, size) + pos += size + 1 + + @classmethod + def split(cls, root, leaf, kind, before, new_pane, size=None): + """Insert new_pane beside leaf. Returns (root, new_leaf).""" + total = cls.size_of(leaf, kind) + if total < 3: + raise MplexError("no space for new pane") + avail = total - 1 + if size is None: + new_size = avail - avail // 2 if before else avail // 2 + else: + new_size = max(1, min(avail - 1, size)) + old_size = avail - new_size + fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h) + parent = leaf.parent + if parent is not None and parent.kind == kind: + index = parent.children.index(leaf) + parent.children.insert(index if before else index + 1, fresh) + fresh.parent = parent + else: + box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h) + box.x, box.y = leaf.x, leaf.y + box.parent = parent + if parent is None: + root = box + else: + parent.children[parent.children.index(leaf)] = box + for item in ((fresh, leaf) if before else (leaf, fresh)): + box.adopt(item) + cls.set_size(leaf, kind, old_size) + cls.set_size(fresh, kind, new_size) + return root, fresh + + @classmethod + def remove(cls, root, leaf): + """Drop leaf, hand its space to a neighbour. Returns the new root (None when empty).""" + parent = leaf.parent + if parent is None: + return None + index = parent.children.index(leaf) + parent.children.remove(leaf) + neighbour = parent.children[index - 1] if index > 0 else parent.children[0] + kind = parent.kind + cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1) + if len(parent.children) == 1: + only = parent.children[0] + grand = parent.parent + only.w, only.h = parent.w, parent.h + only.parent = grand + if grand is None: + return only + slot = grand.children.index(parent) + if only.kind == grand.kind: + grand.children[slot:slot + 1] = only.children + for child in only.children: + child.parent = grand + else: + grand.children[slot] = only + return root + + @classmethod + def resize_by(cls, leaf, kind, delta, prefer_after): + """Move the border of leaf's nearest enclosing "kind" container by delta cells.""" + node = leaf + while node.parent is not None and node.parent.kind != kind: + node = node.parent + parent = node.parent + if parent is None: + raise MplexError("no border to resize") + kids = parent.children + index = kids.index(node) + use_after = (index < len(kids) - 1) if prefer_after else (index == 0) + first = index if use_after else index - 1 + a, b = kids[first], kids[first + 1] + size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind) + new_a = max(1, min(size_a + size_b - 1, size_a + delta)) + cls.set_size(a, kind, new_a) + cls.set_size(b, kind, size_a + size_b - new_a) + + @staticmethod + def shares(total, count, spare_first): + """Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last.""" + room = total - (count - 1) + base, extra = divmod(room, count) + sizes = [base] * count + for index in (range(extra) if spare_first else range(count - extra, count)): + sizes[index] += 1 + return sizes + + @classmethod + def resize_to(cls, leaf, kind, size): + """Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before).""" + change = size - cls.size_of(leaf, kind) + node = leaf + while node.parent is not None and node.parent.kind != kind: + node = node.parent + if node.parent is None: + raise MplexError("no border to resize") + has_next = node.parent.children.index(node) < len(node.parent.children) - 1 + cls.resize_by(leaf, kind, change if has_next else -change, True) + + @classmethod + def preset(cls, name, panes, width, height, main_width, main_height): + count = len(panes) + leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes] + mirrored = name.endswith("-mirrored") + base = name[:-len("-mirrored")] if mirrored else name + if count == 1: + leaves[0].w, leaves[0].h = width, height + return leaves[0] + + def box(kind, items, w, h, spare_first=True): + node = MplexLayoutNode(kind, w=w, h=h) + for item in items: + node.adopt(item) + for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)): + if kind == "lr": + item.w, item.h = size, h + else: + item.w, item.h = w, size + return node + + if base == "even-horizontal": + return box("lr", leaves, width, height) + if base == "even-vertical": + return box("tb", leaves, width, height) + if base in ("main-horizontal", "main-vertical"): + horizontal = base == "main-horizontal" + span = height if horizontal else width + main_size = max(1, min(span - 2, main_height if horizontal else main_width)) + main = leaves[0] + rest = leaves[1:] + other_size = span - main_size - 1 + if horizontal: + others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size) + main.w, main.h, others.w, others.h = width, main_size, width, other_size + root = box("tb", [main, others], width, height) + else: + others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height) + main.w, main.h, others.w, others.h = main_size, height, other_size, height + root = box("lr", [main, others], width, height) + if horizontal: + main.h, others.h = main_size, other_size + else: + main.w, others.w = main_size, other_size + root.reverse = mirrored + return root + rows = columns = 1 + while rows * columns < count: + rows += 1 + if rows * columns < count: + columns += 1 + lines = [] + for start in range(0, count, columns): + chunk = leaves[start:start + columns] + lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False)) + if len(lines) == 1: + return lines[0] + root = box("tb", lines, width, height, spare_first=False) + for line in lines: + line.w = width + return root + + @staticmethod + def neighbour(panes, current, direction, width, height): + left, top = current.x, current.y + right, bottom = current.x + current.w, current.y + current.h + + def overlaps(other): + if direction in ("L", "R"): + return other.y < bottom and other.y + other.h > top + return other.x < right and other.x + other.w > left + + wanted = [] + for other in panes: + if other is current or not overlaps(other): + continue + if direction == "L" and other.x + other.w + 1 == left: + wanted.append(other) + elif direction == "R" and other.x == right + 1: + wanted.append(other) + elif direction == "U" and other.y + other.h + 1 == top: + wanted.append(other) + elif direction == "D" and other.y == bottom + 1: + wanted.append(other) + if not wanted: + for other in panes: + if other is current or not overlaps(other): + continue + if direction == "L" and other.x + other.w >= width: + wanted.append(other) + elif direction == "R" and other.x == 0: + wanted.append(other) + elif direction == "U" and other.y + other.h >= height: + wanted.append(other) + elif direction == "D" and other.y == 0: + wanted.append(other) + if not wanted: + return None + return max(wanted, key=lambda item: item.last_active) + + +class MplexPane: + """One pty-backed shell: its screen, scrollback, output queue and mode stack.""" + + def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"): + self.id = ident + self.window = None + self.x = 0 + self.y = 0 + self.w = max(1, cols) + self.h = max(1, rows) + self.screen = TuiVtScreen(self.h, self.w) + self.screen.history = collections.deque(maxlen=max(0, history_limit)) + self.master_fd = None + self.pid = None + self.dead = False + self.exit_status = None + self.pending_in = bytearray() + self.dropped = 0 + self.modes = [] + self.prefix_count = 1 + self.created = time.time() + self.last_active = 0.0 + self.shell_name = "" + self.last_search = None + self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident)) + self._log = None + self._log_size = 0 + self.input_bytes = 0 + + @property + def tag(self): + return "%%%d" % self.id + + @property + def title(self): + return self.screen.title or socket.gethostname() + + def spawn(self, file, argv, cwd, env): + master, slave = os.openpty() + try: + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) + attrs = termios.tcgetattr(slave) + attrs[0] |= getattr(termios, "IUTF8", 0) + termios.tcsetattr(slave, termios.TCSANOW, attrs) + except (OSError, termios.error): + pass + log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + self._log = os.fdopen(log_fd, "ab", buffering=0) + pid = os.fork() + if pid == 0: + try: + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + for fd in (0, 1, 2): + os.dup2(slave, fd) + if slave > 2: + os.close(slave) + os.close(master) + for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM): + signal.signal(sig, signal.SIG_DFL) + try: + os.chdir(cwd) + except OSError: + pass + os.execvpe(file, argv, env) + except BaseException: + pass + finally: os._exit(127) - return pid, master_fd + os.close(slave) + os.set_blocking(master, False) + self.master_fd = master + self.pid = pid + self.shell_name = os.path.basename(file) - def _open_server(self): + def place(self, x, y, w, h): + self.x, self.y = x, y + w, h = max(1, w), max(1, h) + if (w, h) == (self.w, self.h): + return + self.w, self.h = w, h + self.screen.resize(h, w) + self.apply_winsize() + for mode in self.modes: + mode.on_resize() + + def apply_winsize(self): + if self.master_fd is None: + return try: - os.unlink(self.socket_path) + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) except OSError: pass - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(self.socket_path) - # Same reasoning as the log file: a plain AF_UNIX bind() creates the - # socket file with umask-default permissions, often world-connectable - # on a multi-user machine -- which would let another local user send - # keystrokes into this terminal session. Owner-only, no exceptions. + + def feed(self, data): + history = self.screen.history + before = self.screen.history_pushed + before_len = len(history) if history is not None else 0 + self.screen.feed(data) + pushed = self.screen.history_pushed - before + evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0 + if self._log is not None: + self.log(data) + replies = self.screen.take_replies() + if replies: + self.write(replies) + for mode in self.modes: + mode.on_output(pushed, evicted) + + def log(self, data): + """Append to the session log; at MPLEX_LOG_MAX it rolls over to .1 (one generation kept).""" try: - os.chmod(self.socket_path, 0o600) + if self._log_size + len(data) > MPLEX_LOG_MAX: + self._log.close() + os.replace(self.log_path, self.log_path + ".1") + fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + self._log = os.fdopen(fd, "ab", buffering=0) + self._log_size = 0 + self._log.write(data) + self._log_size += len(data) except OSError: pass - server.listen(8) - server.setblocking(False) - return server - def _recv_request(self, conn): - """Mirrors NativePlexerController._call()'s own framing exactly: loop - until a newline-terminated chunk arrives, not a single recv() call. - json.dumps always escapes a real newline inside a string as the two - characters '\\n', so the one literal 0x0A byte in the whole message - is always the trailing terminator added below -- a single recv() would - silently truncate any request too large for one read (e.g. a long - plexer_type payload), which is exactly the kind of bug that only shows - up under real load, not in a quick manual check.""" - chunks = [] - total = 0 - while True: - chunk = conn.recv(65536) - if not chunk: - break - chunks.append(chunk) - total += len(chunk) - if chunk.endswith(b"\n") or total >= PLEXER_RECV_MAX: - break - return b"".join(chunks) + def write(self, data): + if not data: + return + if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX: + self.dropped += len(data) + return + self.pending_in += data - def _handle_control(self, conn): + def drain(self): + if not self.pending_in or self.master_fd is None: + return try: - conn.settimeout(2) - raw = self._recv_request(conn) - request = json.loads(raw.decode("utf-8", "replace")) if raw.strip() else {} - if not isinstance(request, dict): - raise ValueError("request must be a JSON object") - reply = self._dispatch(request) - except Exception as exc: - # Deliberately broad: a malformed or unexpected request must never - # be able to crash this relay loop and take the user's whole live - # terminal session down with it. This is the one place in the - # whole backend where "catch everything" is the correct choice, - # not laziness -- everything else in this class keeps narrow, - # specific except clauses on purpose. - reply = {"ok": False, "error": short_error(exc)} - try: - conn.sendall((json.dumps(reply) + "\n").encode("utf-8")) - except OSError: + written = os.write(self.master_fd, bytes(self.pending_in[:4096])) + del self.pending_in[:written] + except BlockingIOError: pass - finally: - conn.close() + except OSError: + self.pending_in.clear() - def _dispatch(self, request): - cmd = request.get("cmd") - if cmd == "send": - text = str(request.get("text") or "") - if text: - self._pending_to_child += text.encode("utf-8", "replace") - if request.get("enter"): - self._pending_to_child += b"\r" - return {"ok": True, "data": True} - if cmd == "read": - limit = max(1, min(int(request.get("lines") or 200), 5000)) - return {"ok": True, "data": self._tail_log(limit)} - if cmd == "status": - return {"ok": True, "data": {"backend": "native", "pid": self.pid, "log": self.log_path, "socket": self.socket_path}} - return {"ok": False, "error": "unknown command %r" % cmd} + def top_mode(self): + return self.modes[-1] if self.modes else None - def _tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24): - """Reads only the tail, in growing byte chunks from the end, instead - of loading the whole file -- a session log is exactly the kind of - file this feature encourages to grow large over a long-running - terminal session, and read_pane()/plexer_read get called repeatedly, - so re-reading the entire file on every call would make the backend - slower the longer the very session it's meant to support stays open.""" + def push_mode(self, mode): + self.modes.append(mode) + return mode + + def pop_mode(self, mode=None): + if mode is None: + if self.modes: + self.modes.pop() + elif mode in self.modes: + self.modes.remove(mode) + + def find_mode(self, cls): + for mode in self.modes: + if isinstance(mode, cls): + return mode + return None + + def foreground_command(self): + try: + group = os.tcgetpgrp(self.master_fd) + with open("/proc/%d/comm" % group, "r") as handle: + return handle.read().strip() or self.shell_name + except (OSError, TypeError, ValueError): + return self.shell_name + + def current_path(self): + try: + return os.readlink("/proc/%d/cwd" % self.pid) + except (OSError, TypeError): + return "" + + def history_size(self): + history = self.screen.history + return len(history) if history is not None else 0 + + def clear_history(self): + if self.screen.history is not None: + self.screen.history.clear() + + def close(self, hard=False): + """Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead.""" + if self.pid is not None: + sig = signal.SIGKILL if hard else signal.SIGHUP + try: + os.killpg(self.pid, sig) + except OSError: + try: + os.kill(self.pid, sig) + except OSError: + pass + if self.master_fd is not None: + try: + os.close(self.master_fd) + except OSError: + pass + self.master_fd = None + if self._log is not None: + try: + self._log.close() + except OSError: + pass + self._log = None + + def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24): + """Last lines of the raw session log, reading only the tail in growing chunks.""" try: size = os.path.getsize(self.log_path) except OSError: @@ -6278,191 +7869,4958 @@ class NativePlexerBackend(PlexerBackend): data = handle.read(size - read_from) if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes: break - text = data.decode("utf-8", "replace") - return "\n".join(text.splitlines()[-lines:]) + return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:]) - def run(self): - if pty is None or termios is None or select is None: - return "error: pty/termios/select not available on this platform" - self.pid, self.master_fd = self._spawn_child() - # The log captures the entire terminal session verbatim -- anything typed, - # including passwords and tokens -- so it must never be created world- or - # group-readable. os.open's mode is applied atomically at creation (no - # window where a looser default permission briefly exists), unlike a - # plain open() + a later os.chmod. - log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) - self._log_fh = os.fdopen(log_fd, "ab", buffering=0) - self._server = self._open_server() - self._set_raw() - self._apply_winsize() + +class MplexWindow: + def __init__(self, ident, name, width, height, parent_options): + self.id = ident + self.name = name + self.root = None + self.active = None + self.last_pane = None + self.zoomed = None + self.width = width + self.height = height + self.layout_index = -1 + self.options = MplexOptions(parent_options) + self.auto_rename = True + self.created = time.time() + self.session = None + + @property + def tag(self): + return "@%d" % self.id + + def panes(self): + return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else [] + + def relayout(self): + if self.root is None: + return + MplexLayout.assign(self.root, 0, 0, self.width, self.height) + for leaf in self.root.leaves(): + leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h) + if self.zoomed is not None and self.zoomed in self.panes(): + self.zoomed.place(0, 0, self.width, self.height) + else: + self.zoomed = None + + def visible_panes(self): + return [self.zoomed] if self.zoomed is not None else self.panes() + + def leaf_of(self, pane): + for leaf in self.root.leaves(): + if leaf.pane is pane: + return leaf + return None + + def set_active(self, pane): + if pane is not self.active: + self.last_pane = self.active + self.active = pane + pane.last_active = time.monotonic() + + +class MplexSession: + def __init__(self, ident, name, parent_options, cwd, env): + self.id = ident + self.name = name + self.windows = {} + self.current = None + self.history = [] + self.created = time.time() + self.options = MplexOptions(parent_options) + self.cwd = cwd + self.env = env + self.last_attached = time.time() + + @property + def tag(self): + return "$%d" % self.id + + def window(self): + return self.windows.get(self.current) + + def indexes(self): + return sorted(self.windows) + + def free_index(self, base): + index = base + while index in self.windows: + index += 1 + return index + + @property + def last(self): + """Index of the window that was current before this one, or None.""" + for window in self.history: + index = self.index_of(window) + if index is not None: + return index + return None + + def select(self, index): + if index not in self.windows: + return False + if index != self.current: + previous = self.window() + target = self.windows[index] + self.history = [item for item in self.history if item is not previous and item is not target] + if previous is not None: + self.history.insert(0, previous) + self.current = index + return True + + def index_of(self, window): + for index, item in self.windows.items(): + if item is window: + return index + return None + + +class MplexCanvas: + """A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that + turns two successive grids into the escape sequences that repaint only what changed.""" + + BLANK = (" ", None, None, 0) + GLYPHS = { + "single": "─│┌┐└┘├┤┬┴┼", + "double": "═║╔╗╚╝╠╣╦╩╬", + "heavy": "━┃┏┓┗┛┣┫┳┻╋", + "simple": "-|+++++++++", + } + + def __init__(self, rows, cols): + self.rows = rows + self.cols = cols + self.cells = [[self.BLANK] * cols for _row in range(rows)] + self.cursor = None + + def put(self, x, y, text, style=(None, None, 0), limit=None): + if y < 0 or y >= self.rows: + return x + fg, bg, attr = style + row = self.cells[y] + for char in text: + if limit is not None and limit <= 0: + break + if 0 <= x < self.cols: + row[x] = (char, fg, bg, attr) + x += 1 + if limit is not None: + limit -= 1 + return x + + def put_cells(self, x, y, cells): + if y < 0 or y >= self.rows: + return + row = self.cells[y] + for cell in cells: + if 0 <= x < self.cols: + row[x] = cell + x += 1 + + def blit(self, x, y, block, width): + for index, source in enumerate(block): + target = y + index + if target < 0 or target >= self.rows: + continue + row = self.cells[target] + take = min(width, len(source), self.cols - x) + if take > 0: + row[x:x + take] = source[:take] + + def fill(self, x, y, w, h, style=(None, None, 0), char=" "): + cell = (char, style[0], style[1], style[2]) + for target in range(max(0, y), min(self.rows, y + h)): + row = self.cells[target] + for col in range(max(0, x), min(self.cols, x + w)): + row[col] = cell + + @classmethod + def border_char(cls, up, down, left, right, lines): + glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"]) + horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs + if (up or down) and not (left or right): + return vert + if (left or right) and not (up or down): + return horiz + if up and down and left and right: + return cross + if up and down and right: + return tee_r + if up and down and left: + return tee_l + if left and right and down: + return tee_d + if left and right and up: + return tee_u + if down and right: + return tl + if down and left: + return tr + if up and right: + return bl + if up and left: + return br + return cross + + def encode(self, previous): + """Escape bytes repainting this grid over previous (a list of row tuples, or None).""" + snapshot = [tuple(row) for row in self.cells] + out = ["\x1b[?25l"] + if previous is None or len(previous) != len(snapshot): + out.append("\x1b[0m\x1b[2J") + previous = None + for index, row in enumerate(snapshot): + if previous is not None and previous[index] == row: + continue + out.append("\x1b[%d;1H" % (index + 1)) + current = None + for char, fg, bg, attr in row: + style = (fg, bg, attr) + if style != current: + out.append(MplexStyle.sgr(fg, bg, attr)) + current = style + out.append(char) + out.append("\x1b[0m") + if self.cursor is not None: + out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1)) + return "".join(out).encode("utf-8", "replace"), snapshot + + +class MplexStatusText: + """Format-expanded text with #[...] style markers turned into styled cells.""" + + @staticmethod + def cells(text, base, reset=None): + reset = base if reset is None else reset + current = base + out = [] + pos = 0 + while pos < len(text): + if text.startswith("#[", pos): + end = text.find("]", pos + 2) + if end > 0: + try: + current = MplexStyle.parse(text[pos + 2:end], current, reset) + except MplexError: + pass + pos = end + 1 + continue + out.append((text[pos], current[0], current[1], current[2])) + pos += 1 + return out + + +class MplexMode: + """Pane mode base: owns the pane's keyboard and its drawing while it is on top.""" + + name = "mode" + + def __init__(self, server, pane): + self.server = server + self.pane = pane + + def render(self, client): + raise NotImplementedError + + def key(self, name, raw, client): + return None + + def on_output(self, pushed, evicted): + return None + + def on_resize(self): + return None + + def close(self): + self.pane.pop_mode(self) + self.server.mark_dirty(self.pane.window) + + +class MplexCopyMode(MplexMode): + """Scrollback browser: movement, search, selection and copy, driven by the + copy-mode / copy-mode-vi key tables through "send-keys -X ".""" + + name = "copy-mode" + + def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True): + super().__init__(server, pane) + self.static_rows = None + if lines is not None: + self.name = "view-mode" + self.static_rows = [[(char, None, None, 0) for char in line] for line in lines] + while len(self.static_rows) < pane.h: + self.static_rows.append([]) + self.oy = 0 + self.cx = 0 if lines is not None else pane.screen.cx + self.cy = 0 if lines is not None else pane.screen.cy + self.anchor = None + self.rect = False + self.line_select = False + self.mark = None + self.jump = None + self.inc_origin = None + self.show_position = show_position + self.exit_at_bottom = exit_at_bottom + self.count = 1 + self.lastcx = 0 + self.lastsx = 0 + # a search string outlives copy mode; re-entering it starts searching upward, as tmux does + self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None + self._line_cache = (-1, "") + if lines is not None: + self.oy = self.hist() + + @property + def rows(self): + return self.pane.h + + @property + def cols(self): + return self.pane.w + + def hist(self): + if self.static_rows is not None: + return max(0, len(self.static_rows) - self.rows) + return self.pane.history_size() + + def total(self): + return self.hist() + self.rows + + def top(self): + return self.hist() - self.oy + + def abs_y(self): + return self.top() + self.cy + + def row(self, ay): + if self.static_rows is not None: + return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else [] + hist = self.hist() + if ay < hist: + return self.pane.screen.history[ay] + grid = self.pane.screen.grid + index = ay - hist + return grid[index] if 0 <= index < len(grid) else [] + + def text(self, ay): + return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip() + + def clamp(self): + self.oy = max(0, min(self.hist(), self.oy)) + self.cy = max(0, min(self.rows - 1, self.cy)) + self.cx = max(0, min(self.cols, self.cx)) + + def goto(self, x, ay): + ay = max(0, min(self.total() - 1, ay)) + top = self.top() + if ay < top: + top = ay + elif ay >= top + self.rows: + top = ay - self.rows + 1 + self.oy = max(0, min(self.hist(), self.hist() - top)) + self.cy = ay - self.top() + self.cx = max(0, min(self.cols, x)) + + def on_output(self, pushed, evicted): + if self.static_rows is not None: + return + if self.oy > 0 and pushed: + self.oy += pushed + if evicted: + if self.anchor: + self.anchor = (self.anchor[0], self.anchor[1] - evicted) + if self.mark: + self.mark = (self.mark[0], self.mark[1] - evicted) + self.clamp() + + def on_resize(self): + self.clamp() + + # ---- rendering ------------------------------------------------------- + + def vi_keys(self): + return self.server.pane_options(self.pane).get("mode-keys") == "vi" + + def selected(self, x, ay): + if self.anchor is None: + return False + ax, ay0 = self.anchor + cx, cy = self.cx, self.abs_y() + extra = 1 if self.vi_keys() else 0 + if self.rect: + return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra + if self.line_select: + return min(ay0, cy) <= ay <= max(ay0, cy) + first, last = sorted(((ay0, ax), (cy, cx))) + return first <= (ay, x) < (last[0], last[1] + extra) + + def pattern(self): + if not self.search: + return None + return self.compile(self.search[0], len(self.search) > 2 and self.search[2]) + + def render(self, client): + opts = self.server.pane_options(self.pane) + mode_style = MplexStyle.parse(opts.get("mode-style")) + match_style = MplexStyle.parse(opts.get("copy-mode-match-style")) + pattern = self.pattern() + top = self.top() + out = [] + for view_row in range(self.rows): + ay = top + view_row + source = self.row(ay) + cells = [] + for col in range(self.cols): + cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK + cells.append(cell) + if pattern is not None: + line = "".join(cell[0] for cell in cells) + for match in pattern.finditer(line): + if match.end() == match.start(): + continue + for col in range(match.start(), match.end()): + cells[col] = self.tint(cells[col], match_style) + if self.anchor is not None: + for col in range(self.cols): + if self.selected(col, ay): + cells[col] = self.tint(cells[col], mode_style) + out.append(cells) + if self.show_position and out: + label = "[%d/%d]" % (self.oy, self.hist()) + start = max(0, self.cols - len(label)) + for index, char in enumerate(label[:self.cols]): + out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2]) + return out, (self.cx, self.cy) + + @staticmethod + def tint(cell, style): + char, fg, bg, attr = cell + return (char, style[0] or fg, style[1] or bg, attr | style[2]) + + # ---- keys ------------------------------------------------------------ + + def key(self, name, raw, client): + table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode" + binding = self.server.config.lookup(table, name) + if binding is not None: + self.server.run_binding(client, binding, self.pane) + return True + + def command(self, name, words, client): + """Run one copy-mode command (send-keys -X name args) count times.""" + method = getattr(self, "c_" + name.replace("-", "_"), None) + if method is None: + raise MplexError("unknown copy mode command: %s" % name) + count = max(1, min(self.pane.prefix_count, 10000)) + self.pane.prefix_count = 1 + self.count = count + self._line_cache = (-1, "") + result = method(words, client) + self.clamp() + self.server.mark_dirty(self.pane.window) + if result == "cancel": + self.close() + + # ---- cursor and scrolling -------------------------------------------- + # The cursor sits between characters like tmux's: it may stand one past the + # last character of a line (x == line length), and vertical moves remember the + # column they started from (lastcx / lastsx), snapping to the line end the way + # tmux does. These rules were checked against tmux 3.6 with a differential fuzz. + + def llen(self, ay): + return len(self.text(ay)) + + def snap(self): + px = self.llen(self.abs_y()) + if (self.cx >= self.lastsx and self.cx != px) or self.cx > px: + self.cx = px + + def c_cancel(self, words, client): + return "cancel" + + def step_up(self, scroll_only): + ox = self.llen(self.abs_y()) + if self.cx != ox: + self.lastcx, self.lastsx = self.cx, ox + self.cx = self.lastcx + if scroll_only or self.cy == 0: + if self.oy < self.hist(): + self.oy += 1 + else: + self.cy -= 1 + self.snap() + + def step_down(self, scroll_only): + ox = self.llen(self.abs_y()) + if self.cx != ox: + self.lastcx, self.lastsx = self.cx, ox + self.cx = self.lastcx + if scroll_only or self.cy == self.rows - 1: + if self.oy > 0: + self.oy -= 1 + else: + self.cy += 1 + self.snap() + + def c_cursor_up(self, words, client): + for _i in range(self.count): + self.step_up(False) + + def c_cursor_down(self, words, client): + for _i in range(self.count): + self.step_down(False) + return self.bottom_exit() + + def c_cursor_down_and_cancel(self, words, client): + for _i in range(self.count): + self.step_down(False) + if self.oy == 0 and self.cy == self.rows - 1: + return "cancel" + + def bottom_exit(self): + return "cancel" if self.exit_at_bottom and self.oy == 0 else None + + def c_cursor_left(self, words, client): + for _i in range(self.count): + ay = self.abs_y() + if self.cx == 0 and ay > 0: + self.goto(self.llen(ay - 1), ay - 1) + elif self.cx > 0: + self.cx -= 1 + + def c_cursor_right(self, words, client): + for _i in range(self.count): + ay = self.abs_y() + if self.cx >= self.llen(ay): + if ay < self.total() - 1: + self.goto(0, ay + 1) + else: + self.cx += 1 + + def c_start_of_line(self, words, client): + self.cx = 0 + + def c_end_of_line(self, words, client): + self.cx = self.llen(self.abs_y()) + + def c_back_to_indentation(self, words, client): + line = self.text(self.abs_y()) + if line: + self.cx = len(line) - len(line.lstrip(" ")) + + def c_top_line(self, words, client): + self.cx, self.cy = 0, 0 + + def c_middle_line(self, words, client): + self.cx, self.cy = 0, (self.rows - 1) // 2 + + def c_bottom_line(self, words, client): + self.cx, self.cy = 0, self.rows - 1 + + def c_history_top(self, words, client): + self.oy, self.cx, self.cy = self.hist(), 0, 0 + + def c_history_bottom(self, words, client): + self.oy, self.cy = 0, self.rows - 1 + self.cx = self.llen(self.abs_y()) + return self.bottom_exit() + + def page_size(self, half): + return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1 + + def remember_column(self): + ox = self.llen(self.abs_y()) + if self.cx != ox: + self.lastcx, self.lastsx = self.cx, ox + self.cx = self.lastcx + + def page_up(self, half): + size = self.page_size(half) + self.remember_column() + if self.oy + size > self.hist(): + self.oy = self.hist() + self.cy = self.cy - size if self.cy > size else 0 + else: + self.oy += size + self.snap() + + def page_down(self, half): + size = self.page_size(half) + self.remember_column() + if self.oy < size: + self.cy = min(self.rows - 1, self.cy + size) + self.oy = 0 + else: + self.oy -= size + self.snap() + + def c_page_up(self, words, client): + for _i in range(self.count): + self.page_up(False) + + def c_page_down(self, words, client): + for _i in range(self.count): + self.page_down(False) + return self.bottom_exit() + + def c_page_down_and_cancel(self, words, client): + for _i in range(self.count): + self.page_down(False) + if self.oy == 0: + return "cancel" + + def c_halfpage_up(self, words, client): + for _i in range(self.count): + self.page_up(True) + + def c_halfpage_down(self, words, client): + for _i in range(self.count): + self.page_down(True) + return self.bottom_exit() + + def c_halfpage_down_and_cancel(self, words, client): + for _i in range(self.count): + self.page_down(True) + if self.oy == 0: + return "cancel" + + def c_scroll_up(self, words, client): + for _i in range(self.count): + self.step_up(True) + + def c_scroll_down(self, words, client): + for _i in range(self.count): + self.step_down(True) + return self.bottom_exit() + + def c_scroll_down_and_cancel(self, words, client): + for _i in range(self.count): + self.step_down(True) + if self.oy == 0: + return "cancel" + + def c_scroll_middle(self, words, client): + mid = (self.rows - 1) // 2 + delta = self.cy - mid + if delta > 0 and self.oy >= delta: + self.oy -= delta + self.cy = mid + elif delta < 0 and self.hist() - self.oy >= -delta: + self.oy += -delta + self.cy = mid + + def c_cursor_centre_vertical(self, words, client): + self.cy = self.rows // 2 + + def c_cursor_centre_horizontal(self, words, client): + return None + + def c_scroll_top(self, words, client): + if self.oy >= self.cy: + self.oy -= self.cy + self.cy = 0 + + def c_scroll_bottom(self, words, client): + need = self.rows - 1 - self.cy + if self.hist() - self.oy >= need: + self.oy += need + self.cy = self.rows - 1 + + def c_goto_line(self, words, client): try: - old_handler = signal.signal(signal.SIGWINCH, self._on_sigwinch) if hasattr(signal, "SIGWINCH") else None - except (ValueError, OSError): - old_handler = None - self._running = True - crash = None + line = int(words[0]) + except (IndexError, ValueError): + raise MplexError("goto-line needs a line number") + self.oy = max(0, min(self.hist(), line)) + + def c_refresh_from_pane(self, words, client): + return None + + def c_toggle_position(self, words, client): + self.show_position = not self.show_position + + # ---- words, paragraphs, brackets ------------------------------------- + + def in_ws(self, x, ay): + return self.char_at(x, ay) == " " + + def in_sep(self, x, ay, separators): + return self.char_at(x, ay) in separators + + def separators(self, big): + return "" if big else self.server.pane_options(self.pane).get("word-separators") + + def char_at(self, x, ay): + if self._line_cache[0] != ay: + self._line_cache = (ay, self.text(ay)) + line = self._line_cache[1] + return line[x] if x < len(line) else " " + + def advance_blanks(self, x, ay, xx): + """Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom).""" + last = self.total() - 1 + while x > xx or self.in_ws(x, ay): + if x > xx: + if ay == last: + return x, ay, xx, True + ay += 1 + x = 0 + xx = self.llen(ay) + else: + x += 1 + return x, ay, xx, False + + def run_end(self, x, ay, xx, separators): + """x just past the run of separators (or of word characters) that starts at x.""" + if self.in_sep(x, ay, separators) and not self.in_ws(x, ay): + x += 1 + while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay): + x += 1 + else: + x += 1 + while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)): + x += 1 + return x + + def c_next_word(self, words, client, big=False): + separators = self.separators(big) + x, ay = self.cx, self.abs_y() + for _i in range(self.count): + xx = self.llen(ay) + if not self.in_ws(x, ay): + x = self.run_end(x, ay, xx, separators) + x, ay, xx, _bottom = self.advance_blanks(x, ay, xx) + self.goto(x, ay) + + def c_next_space(self, words, client): + self.c_next_word(words, client, True) + + def c_next_word_end(self, words, client, big=False): + separators = self.separators(big) + vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" + x, ay = self.cx, self.abs_y() + for _i in range(self.count): + xx = self.llen(ay) + if vi: + if x < xx: + x += 1 + elif ay < self.total() - 1: + x, ay = 0, ay + 1 + xx = self.llen(ay) + else: + break + x, ay, xx, bottom = self.advance_blanks(x, ay, xx) + 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): + self.c_next_word_end(words, client, True) + + def c_previous_word(self, words, client, big=False): + separators = self.separators(big) + x, ay = self.cx, self.abs_y() + for _i in range(self.count): + # one step back, then over whitespace (and empty lines) to a word character + while True: + if x == 0: + if ay == 0: + break + ay -= 1 + x = self.llen(ay) + else: + x -= 1 + if not self.in_ws(x, ay): + break + if self.in_ws(x, ay): + break + letters = not self.in_sep(x, ay, separators) + while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)): + x -= 1 + self.goto(x, ay) + + def c_previous_space(self, words, client): + self.c_previous_word(words, client, True) + + def c_next_paragraph(self, words, client): + ay = self.abs_y() + last = self.total() - 1 + for _i in range(self.count): + while ay < last and not self.llen(ay): + ay += 1 + while ay < last and self.llen(ay): + ay += 1 + self.scroll_to(self.llen(ay), ay) + + def c_previous_paragraph(self, words, client): + ay = self.abs_y() + for _i in range(self.count): + while ay > 0 and not self.llen(ay): + ay -= 1 + while ay > 0 and self.llen(ay): + ay -= 1 + self.scroll_to(0, ay) + + def match_bracket(self, x, ay, forward): + """Position of the bracket matching the one at (x, ay), or None.""" + pairs = {"(": ")", "[": "]", "{": "}"} + reverse = {value: key for key, value in pairs.items()} + opener = self.char_at(x, ay) + closer = pairs.get(opener) if forward else reverse.get(opener) + if closer is None: + return None + depth = 0 + pos = (x, ay) + for _i in range(20000): + char = self.char_at(*pos) + if char == opener: + depth += 1 + elif char == closer: + depth -= 1 + if depth == 0: + return pos + pos = self.step(pos[0], pos[1], forward) + if pos is None: + return None + return None + + def step(self, x, ay, forward): + """Next position, line ends counting as one blank cell. None at the buffer edge.""" + if forward: + if x < self.llen(ay): + return x + 1, ay + return (0, ay + 1) if ay + 1 < self.total() else None + if x > 0: + return x - 1, ay + return (self.llen(ay - 1), ay - 1) if ay > 0 else None + + def c_next_matching_bracket(self, words, client): + ay = self.abs_y() + if not self.llen(ay): + return + here = self.char_at(self.cx, ay) + found = None + if self.vi_keys(): + if here in "([{": + found = self.match_bracket(self.cx, ay, True) + elif here in ")]}": + found = self.match_bracket(self.cx, ay, False) + else: + for x in (self.cx, self.cx + 1): + if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "): + found = self.match_bracket(x, ay, True) + if found is not None: + self.goto(*found) + else: + self.c_next_word_end([], None, True) + + def c_previous_matching_bracket(self, words, client): + ay = self.abs_y() + if not self.llen(ay): + return + here = self.char_at(self.cx, ay) + found = None + if here in ")]}": + found = self.match_bracket(self.cx, ay, False) + elif self.vi_keys(): + if here in "([{" or self.char_at(self.cx + 1, ay) in "([{": + return + elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}": + found = self.match_bracket(self.cx - 1, ay, False) + if found is not None: + self.goto(*found) + else: + self.c_previous_word([], None, True) + + # ---- jump ------------------------------------------------------------ + + def do_jump(self, kind, char): + line = self.text(self.abs_y()) + x = self.cx + for _i in range(self.count): + if kind == "f": + found = line.find(char, x + 1) + elif kind == "t": + found = line.find(char, x + 2) + found = found - 1 if found >= 0 else -1 + elif kind == "F": + found = line.rfind(char, 0, x) + else: + found = line.rfind(char, 0, max(0, x - 1)) + found = found + 1 if found >= 0 else -1 + if found < 0: + return + x = found + self.cx = x + + def set_jump(self, kind, words): + if not words or not words[0]: + raise MplexError("jump needs a character") + self.jump = (kind, words[0][0]) + self.do_jump(*self.jump) + + def c_jump_forward(self, words, client): + self.set_jump("f", words) + + def c_jump_backward(self, words, client): + self.set_jump("F", words) + + def c_jump_to_forward(self, words, client): + self.set_jump("t", words) + + def c_jump_to_backward(self, words, client): + self.set_jump("T", words) + + def c_jump_again(self, words, client): + if self.jump: + self.do_jump(*self.jump) + + def c_jump_reverse(self, words, client): + if self.jump: + opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]] + self.do_jump(opposite, self.jump[1]) + + # ---- selection and copy ---------------------------------------------- + + def c_begin_selection(self, words, client): + self.anchor = (self.cx, self.abs_y()) + self.line_select = False + + def c_stop_selection(self, words, client): + return None + + def c_clear_selection(self, words, client): + self.anchor = None + self.line_select = False + + def c_select_line(self, words, client): + self.anchor = (0, self.abs_y()) + self.line_select = True + self.rect = False + for _i in range(self.count - 1): + self.step_down(False) + self.cx = self.llen(self.abs_y()) + + def c_select_word(self, words, client): + ay = self.abs_y() + separators = self.separators(False) + line = self.text(ay) + if not line: + return + start = min(self.cx, len(line) - 1) + while start > 0 and self.in_ws(start, ay): + start -= 1 + if self.in_ws(start, ay): + return + letters = not self.in_sep(start, ay, separators) + end = start + while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)): + start -= 1 + while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)): + end += 1 + self.anchor = (start, ay) + self.line_select = False + self.cx = end if self.vi_keys() else end + 1 + + def c_rectangle_toggle(self, words, client): + self.rect = not self.rect + + def c_rectangle_on(self, words, client): + self.rect = True + + def c_rectangle_off(self, words, client): + self.rect = False + + def c_other_end(self, words, client): + if self.anchor is None: + return + old = (self.cx, self.abs_y()) + self.goto(*self.anchor) + self.anchor = old + + def c_set_mark(self, words, client): + self.mark = (self.cx, self.abs_y()) + + def c_jump_to_mark(self, words, client): + if self.mark is None: + return + old = (self.cx, self.abs_y()) + self.goto(*self.mark) + self.mark = old + + def selection_text(self): + """The selected text. Emacs keys: the cursor stands between characters, so the end is + exclusive; vi keys: the character under the cursor is included, and so is the line end + (a newline) when the cursor stands on it. Both checked against tmux 3.6.""" + if self.anchor is None: + return "" + vi = self.vi_keys() + ax, ay0 = self.anchor + cx, cy = self.cx, self.abs_y() + extra = 1 if vi else 0 + if self.line_select: + lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)] + return "\n".join(lines) + ("\n" if vi else "") + if self.rect: + left, right = min(ax, cx), max(ax, cx) + extra + return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1)) + (sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx))) + parts = [] + for ay in range(sy, ey + 1): + line = self.text(ay) + first = sx if ay == sy else 0 + last = ex + extra if ay == ey else len(line) + piece = line[first:last] + if ay == ey and vi and last > len(line): + piece += "\n" + parts.append(piece) + return "\n".join(parts) + + def deliver(self, text, words, append=False): + if not text: + return + command = words[0] if words else "" + self.server.store_copy(text, command, append) + + def c_copy_selection(self, words, client): + self.deliver(self.selection_text(), []) + self.c_clear_selection(words, client) + + def c_copy_selection_and_cancel(self, words, client): + self.deliver(self.selection_text(), []) + return "cancel" + + def c_copy_pipe(self, words, client): + self.deliver(self.selection_text(), words) + self.c_clear_selection(words, client) + + def c_copy_pipe_and_cancel(self, words, client): + self.deliver(self.selection_text(), words) + return "cancel" + + def c_append_selection(self, words, client): + self.deliver(self.selection_text(), [], True) + self.c_clear_selection(words, client) + + def c_append_selection_and_cancel(self, words, client): + self.deliver(self.selection_text(), [], True) + return "cancel" + + def copy_span(self, from_start, words, cancel): + """copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines.""" + ay = self.abs_y() + self.anchor = (0 if from_start else self.cx, ay) + self.line_select = False + self.rect = False + for _i in range(self.count - 1): + self.step_down(False) + self.cx = self.llen(self.abs_y()) + self.deliver(self.selection_text(), words) + self.c_clear_selection(words, None) + return "cancel" if cancel else None + + def c_copy_end_of_line(self, words, client): + return self.copy_span(False, [], False) + + def c_copy_end_of_line_and_cancel(self, words, client): + return self.copy_span(False, [], True) + + def c_copy_pipe_end_of_line(self, words, client): + return self.copy_span(False, words, False) + + def c_copy_pipe_end_of_line_and_cancel(self, words, client): + return self.copy_span(False, words, True) + + def c_copy_line(self, words, client): + return self.copy_span(True, [], False) + + def c_copy_line_and_cancel(self, words, client): + return self.copy_span(True, [], True) + + def c_copy_pipe_line(self, words, client): + return self.copy_span(True, words, False) + + def c_copy_pipe_line_and_cancel(self, words, client): + return self.copy_span(True, words, True) + + # ---- search ---------------------------------------------------------- + + def compile(self, text, literal=False): + flags = re.IGNORECASE if text == text.lower() else 0 + if literal: + return re.compile(re.escape(text), flags) try: - self._relay_loop() - except Exception as exc: # noqa: BLE001 -- see comment below - # Deliberately broad, and deliberately not re-raised bare: letting - # an unexpected exception propagate straight out of run() would - # skip the waitpid() below entirely (a bare `finally` only runs - # cleanup, it does not stop the exception from continuing past - # it), orphaning the wrapped shell with no supervisor left to - # reap it. The user's actual terminal child process must not be - # allowed to leak just because something in the relay loop broke. - crash = exc - finally: - self._running = False - if old_handler is not None: + return re.compile(text, flags) + except re.error: + return None + + def find(self, pattern, forward, x, ay, wrap): + """First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line).""" + total = self.total() + + def matches_of(index): + return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()] + + order = range(ay, total) if forward else range(ay, -1, -1) + for index in order: + found = matches_of(index) + if index == ay: + found = [m for m in found if (m.start() >= x if forward else m.start() < x)] + if found: + match = found[0] if forward else found[-1] + return match.start(), match.end(), index + if wrap: + for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)): + found = matches_of(index) + if found: + match = found[0] if forward else found[-1] + return match.start(), match.end(), index + return None + + def scroll_to(self, x, ay): + """Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view.""" + hist, rows = self.hist(), self.rows + self.cx = max(0, min(self.cols, x)) + top = hist - self.oy + if top <= ay < top + rows: + self.cy = ay - top + return + gap = rows // 4 + if ay < rows: + offset, self.cy = 0, ay + elif ay > hist + rows - gap: + offset, self.cy = hist, ay - hist + else: + offset, self.cy = ay + gap - rows, rows - gap + self.oy = max(0, min(hist, hist - offset)) + + def run_search(self, text, forward, literal=False, origin=None): + if not text: + return False + self.search = (text, forward, literal) + if self.static_rows is None: + self.pane.last_search = self.search + pattern = self.compile(text, literal) + if pattern is None: + return False + wrap = bool(self.server.pane_options(self.pane).get("wrap-search")) + vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" + x, ay = origin if origin is not None else (self.cx, self.abs_y()) + if forward and vi and origin is None: + x += 1 + found = self.find(pattern, forward, x, ay, wrap) + if found is None: + return False + start, end, line = found + self.scroll_to(end if forward and not vi else start, line) + return True + + def search_command(self, words, forward, literal=False): + 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") + + def c_search_forward(self, words, client): + self.search_command(words, True) + + def c_search_backward(self, words, client): + self.search_command(words, False) + + def c_search_forward_text(self, words, client): + self.search_command(words, True, True) + + def c_search_backward_text(self, words, client): + self.search_command(words, False, True) + + 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") + + 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 = (text, forward, literal) + + def incremental(self, words, forward): + arg = words[0] if words else "" + if self.inc_origin is None: + self.inc_origin = (self.cx, self.abs_y()) + mark = arg[:1] + if mark == "=": + origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1]) + self.run_search(arg[1:], forward, origin=origin) + elif mark in ("+", "-"): + self.run_search(arg[1:], mark == "+") + else: + self.inc_origin = None + if arg: + self.search = (arg, forward, False) + + def c_search_forward_incremental(self, words, client): + self.incremental(words, True) + + def c_search_backward_incremental(self, words, client): + self.incremental(words, False) + + def search_cancel(self): + if self.inc_origin is not None: + self.goto(*self.inc_origin) + self.inc_origin = None + self.server.mark_dirty(self.pane.window) + + +class MplexPrompt: + """The command line in the status row: emacs-style editing, history, and the + single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants.""" + + def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False, + on_change=None, on_cancel=None, on_step=None, confirm=False): + self.label = label + self.text = text + self.pos = len(text) + self.on_done = on_done + self.history = history if history is not None else [] + self.hist_index = None + self.single = single + self.numeric = numeric + self.key_name = key_name + self.on_change = on_change + self.on_cancel = on_cancel + self.on_step = on_step + self.confirm = confirm + + def view(self): + return self.label + self.text, len(self.label) + self.pos + + def finish(self, server, client, value): + client.prompt = None + if value and not (self.single or self.numeric or self.key_name or self.confirm): + self.history.append(value) + del self.history[:-MPLEX_PROMPT_HISTORY_CAP] + self.on_done(value) + + def cancel(self, client): + client.prompt = None + if self.on_cancel: + self.on_cancel() + + def changed(self): + if self.on_change: + self.on_change(self.text) + + @staticmethod + def printable(name, raw): + if name == "Space": + return " " + if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable(): + return name + return "" + + def key(self, server, client, name, raw): + char = self.printable(name, raw) + if self.confirm: + if char == "y": + self.finish(server, client, "y") + else: + self.cancel(client) + return + if self.key_name: + self.finish(server, client, name) + return + if self.single: + if char: + self.finish(server, client, char) + elif name in ("Escape", "C-c", "C-g"): + self.cancel(client) + return + if self.numeric: + if char.isdigit(): + self.text += char + self.pos = len(self.text) + return + self.finish(server, client, self.text) + client.redispatch(name, raw) + return + text, pos = self.text, self.pos + if name in ("Enter", "C-j"): + self.finish(server, client, text) + return + if name in ("Escape", "C-c", "C-g"): + self.cancel(client) + return + if name in ("C-a", "Home"): + pos = 0 + elif name in ("C-e", "End"): + pos = len(text) + elif name in ("C-b", "Left"): + pos = max(0, pos - 1) + elif name in ("C-f", "Right"): + pos = min(len(text), pos + 1) + elif name == "M-b": + while pos > 0 and text[pos - 1] == " ": + pos -= 1 + while pos > 0 and text[pos - 1] != " ": + pos -= 1 + elif name == "M-f": + while pos < len(text) and text[pos] == " ": + pos += 1 + while pos < len(text) and text[pos] != " ": + pos += 1 + elif name in ("BSpace", "C-h"): + if pos > 0: + text = text[:pos - 1] + text[pos:] + pos -= 1 + elif name in ("DC", "C-d"): + text = text[:pos] + text[pos + 1:] + elif name == "C-k": + text = text[:pos] + elif name == "C-u": + text = text[pos:] + pos = 0 + elif name == "C-w": + start = pos + while start > 0 and text[start - 1] == " ": + start -= 1 + while start > 0 and text[start - 1] != " ": + start -= 1 + text = text[:start] + text[pos:] + pos = start + elif name == "C-y": + piece = server.top_buffer_text() + text = text[:pos] + piece + text[pos:] + pos += len(piece) + elif name == "C-t": + if 0 < pos < len(text): + text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:] + pos += 1 + elif name in ("Up", "C-p", "Down", "C-n"): + if self.history: + if name in ("Up", "C-p"): + self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1) + elif self.hist_index is not None: + self.hist_index += 1 + if self.hist_index is not None and self.hist_index >= len(self.history): + self.hist_index = None + text = "" + elif self.hist_index is not None: + text = self.history[self.hist_index] + pos = len(text) + elif name in ("C-s", "C-r") and self.on_step: + self.on_step(name, text) + return + elif char: + text = text[:pos] + char + text[pos:] + pos += len(char) + else: + return + edited = text != self.text + self.text, self.pos = text, pos + if edited: + self.changed() + + +class MplexFont: + GLYPHS = { + "0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."), + "2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"), + "4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"), + "6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"), + "8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"), + ":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"), + "P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"), + } + HEIGHT = 5 + + @classmethod + def width(cls, text): + return len(text) * 6 - 1 if text else 0 + + @classmethod + def draw(cls, text, bg): + """Five rows of cells for text in big glyphs, lit pixels painted with background bg.""" + rows = [] + lit = (" ", None, bg, 0) + dark = MplexCanvas.BLANK + for line in range(cls.HEIGHT): + row = [] + for index, char in enumerate(text): + glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"]) + row.extend(lit if pixel == "#" else dark for pixel in glyph[line]) + if index != len(text) - 1: + row.append(dark) + rows.append(row) + return rows + + +class MplexClockMode(MplexMode): + name = "clock-mode" + + def render(self, client): + opts = self.server.pane_options(self.pane) + bg = MplexStyle.color(opts.get("clock-mode-colour"), True) + now = time.localtime() + text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now) + grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)] + if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text): + big = MplexFont.draw(text, bg) + top = (self.pane.h - MplexFont.HEIGHT) // 2 + left = (self.pane.w - MplexFont.width(text)) // 2 + for index, row in enumerate(big): + grid[top + index][left:left + len(row)] = row + else: + top = self.pane.h // 2 + left = max(0, (self.pane.w - len(text)) // 2) + for index, char in enumerate(text[:self.pane.w]): + grid[top][left + index] = (char, None, bg, 0) + return grid, None + + def key(self, name, raw, client): + self.close() + return True + + +class MplexPaneNumbers: + """display-panes overlay: a big index on every pane until a key or the timer ends it.""" + + def __init__(self, server, window, until): + self.server = server + self.window = window + self.until = until + + def key(self, name, raw, client): + client.overlay = None + if len(name) == 1 and name.isdigit(): + base = self.server.pane_options(self.window.active).get("pane-base-index") + panes = self.window.panes() + index = int(name) - base + if 0 <= index < len(panes): + self.server.select_pane(self.window, panes[index]) + self.server.mark_dirty(self.window) + + def draw(self, canvas, area_y): + opts = self.window.options + base = opts.get("pane-base-index") + normal = MplexStyle.color(opts.get("display-panes-colour"), True) + active = MplexStyle.color(opts.get("display-panes-active-colour"), True) + for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()): + bg = active if pane is self.window.active else normal + text = str(index + base) + if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text): + big = MplexFont.draw(text, bg) + top = pane.y + (pane.h - MplexFont.HEIGHT) // 2 + left = pane.x + (pane.w - MplexFont.width(text)) // 2 + for offset, row in enumerate(big): + canvas.put_cells(left, area_y + top + offset, row) + else: + canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0)) + size = "%dx%d" % (pane.w, pane.h) + if pane.w > len(size) + 1: + canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0)) + + +class MplexChoice: + __slots__ = ("label", "payload", "children", "expanded", "depth", "tag") + + def __init__(self, label, payload=None, children=None, expanded=False): + self.label = label + self.payload = payload + self.children = children or [] + self.expanded = expanded + self.depth = 0 + self.tag = False + + +class MplexChooser(MplexMode): + """choose-tree / choose-buffer / choose-client: a list you move through, with an + optional live preview of the selected item below it.""" + + name = "tree-mode" + HOT_KEYS = "0123456789" + + def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None): + super().__init__(server, pane) + self.name = kind + self.builder = builder + self.items = builder() + self.on_close = on_close + self.on_choose = on_choose + self.on_delete = on_delete + self.preview = preview + self.show_preview = preview is not None + self.cursor = 0 + self.visible = [] + self.reflatten() + if selected is not None: + for index, item in enumerate(self.visible): + if item.payload == selected: + self.cursor = index + + def reflatten(self): + self.visible = [] + + def walk(nodes, depth): + for node in nodes: + node.depth = depth + self.visible.append(node) + if node.children and node.expanded: + walk(node.children, depth + 1) + + walk(self.items, 0) + self.cursor = max(0, min(len(self.visible) - 1, self.cursor)) + + def selected(self): + return self.visible[self.cursor] if self.visible else None + + def rebuild(self): + opened = set() + + def collect(nodes): + for node in nodes: + if node.expanded: + opened.add(repr(node.payload)) + collect(node.children) + + def restore(nodes): + for node in nodes: + node.expanded = node.expanded or repr(node.payload) in opened + restore(node.children) + + collect(self.items) + self.items = self.builder() + restore(self.items) + self.reflatten() + if not self.visible: + self.close() + + def close(self): + super().close() + if self.on_close is not None: + callback, self.on_close = self.on_close, None + callback() + + def list_height(self): + if self.show_preview and self.pane.h >= 8: + return max(3, self.pane.h // 2) + return self.pane.h + + def render(self, client): + w, h = self.pane.w, self.pane.h + opts = self.server.pane_options(self.pane) + mode_style = MplexStyle.parse(opts.get("mode-style")) + grid = [[MplexCanvas.BLANK] * w for _row in range(h)] + list_h = self.list_height() + top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0 + for row in range(list_h): + index = top + row + if index >= len(self.visible): + break + node = self.visible[index] + hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " " + marker = ("- " if node.expanded else "+ ") if node.children else " " + line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w) + style = mode_style if index == self.cursor else (None, None, 0) + grid[row] = [(char, style[0], style[1], style[2]) for char in line] + if self.show_preview and list_h < h: + border = MplexCanvas.GLYPHS["single"][0] + grid[list_h] = [(border, None, None, 0)] * w + chosen = self.selected() + if chosen is not None: + block = self.preview(chosen, w, h - list_h - 1) or [] + for offset, cells in enumerate(block[:h - list_h - 1]): + grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w] + return grid, None + + def move(self, delta): + self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta)) + + def key(self, name, raw, client): + node = self.selected() + page = max(1, self.list_height() - 1) + if name in ("q", "Escape", "C-c"): + self.close() + elif name in ("Up", "k", "C-p"): + self.move(-1) + elif name in ("Down", "j", "C-n"): + self.move(1) + elif name in ("PPage", "C-b"): + self.move(-page) + elif name in ("NPage", "C-f"): + self.move(page) + elif name in ("Home", "g"): + self.cursor = 0 + elif name in ("End", "G"): + self.cursor = max(0, len(self.visible) - 1) + elif name in ("Right", "l", "+") and node is not None and node.children: + node.expanded = True + self.reflatten() + elif name in ("Left", "h", "-") and node is not None: + if node.children and node.expanded: + node.expanded = False + else: + for index in range(self.cursor - 1, -1, -1): + if self.visible[index].depth < node.depth: + self.cursor = index + break + self.reflatten() + elif name == "t" and node is not None: + node.tag = not node.tag + elif name == "v" and self.preview is not None: + self.show_preview = not self.show_preview + elif name == "x" and node is not None and self.on_delete is not None: + self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client)) + elif name == "Enter" and node is not None: + self.close() + self.on_choose(node, client) + elif len(name) == 1 and name in self.HOT_KEYS: + index = self.HOT_KEYS.index(name) + if index < len(self.visible): + self.cursor = index + self.close() + self.on_choose(self.visible[index], client) + self.server.mark_dirty(self.pane.window) + return True + + def delete(self, node, client): + self.on_delete(node, client) + self.server.refresh_choosers() + + +class MplexRenderer: + """Composes one client's whole screen: panes, borders, status row, prompt, overlays.""" + + def __init__(self, server): + self.server = server + + def block_of(self, pane, client): + mode = pane.top_mode() + if mode is not None: + return mode.render(client) + grid = pane.screen.grid + blank = MplexCanvas.BLANK + block = [[cell if cell is not None else blank for cell in row] for row in grid] + cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None + return block, cursor + + def frame(self, client): + server = self.server + canvas = MplexCanvas(client.rows, client.cols) + session = client.session + window = session.window() + opts = window.options + status_on = bool(opts.get("status")) + on_top = opts.get("status-position") == "top" + area_y = 1 if (status_on and on_top) else 0 + cursor = None + active = window.active + for pane in window.visible_panes(): + block, pane_cursor = self.block_of(pane, client) + canvas.blit(pane.x, area_y + pane.y, block, pane.w) + if pane is active and pane_cursor is not None: + cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1]) + if window.zoomed is None and len(window.panes()) > 1: + self.borders(canvas, window, area_y) + if isinstance(client.overlay, MplexPaneNumbers): + client.overlay.draw(canvas, area_y) + cursor = None + status_row = 0 if on_top else client.rows - 1 + if status_on: + self.status(canvas, client, session, window, status_row) + line_row = status_row if status_on else client.rows - 1 + if client.prompt is not None: + text, pos = client.prompt.view() + style = MplexStyle.parse(opts.get("message-style")) + canvas.fill(0, line_row, client.cols, 1, style) + canvas.put(0, line_row, text, style, client.cols) + cursor = (min(client.cols - 1, pos), line_row) + elif client.message is not None and time.monotonic() < client.message[1]: + style = MplexStyle.parse(opts.get("message-style")) + canvas.fill(0, line_row, client.cols, 1, style) + canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols) + canvas.cursor = cursor + return canvas + + def borders(self, canvas, window, area_y): + W, H = window.width, window.height + panes = window.panes() + cells = set() + for pane in panes: + if pane.x + pane.w < W: + for row in range(pane.y, min(H, pane.y + pane.h + 1)): + cells.add((pane.x + pane.w, row)) + if pane.y + pane.h < H: + for col in range(pane.x, min(W, pane.x + pane.w + 1)): + cells.add((col, pane.y + pane.h)) + active = window.active + hot = set() + if active is not None: + for col in range(active.x - 1, active.x + active.w + 1): + hot.add((col, active.y - 1)) + hot.add((col, active.y + active.h)) + for row in range(active.y - 1, active.y + active.h + 1): + hot.add((active.x - 1, row)) + hot.add((active.x + active.w, row)) + hot &= cells + opts = window.options + lines = opts.get("pane-border-lines") + plain = MplexStyle.parse(opts.get("pane-border-style")) + hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active)) + hot_style = MplexStyle.parse(hot_text, plain) + for (x, y) in cells: + style = hot_style if (x, y) in hot else plain + glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines) + canvas.put(x, area_y + y, glyph, style) + + def status(self, canvas, client, session, window, row): + server = self.server + opts = window.options + base = MplexStyle.parse(opts.get("status-style")) + canvas.fill(0, row, client.cols, 1, base) + resolver = server.resolver_for(session, window, window.active, client) + left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)), + MplexStyle.parse(opts.get("status-left-style"), base), base) + right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)), + MplexStyle.parse(opts.get("status-right-style"), base), base) + left = left[:opts.get("status-left-length")] + right = right[:opts.get("status-right-length")] + if len(left) > client.cols: + left = left[:client.cols] + room_right = max(0, client.cols - len(left)) + right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right] + avail = client.cols - len(left) - len(right) + entries = [] + current_at = 0 + total = 0 + last_index = session.last + separator = opts.get("window-status-separator") + for index in session.indexes(): + item = session.windows[index] + wres = server.resolver_for(session, item, item.active, client) + is_current = index == session.current + fmt = item.options.get("window-status-current-format" if is_current else "window-status-format") + style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style") + style = MplexStyle.parse(item.options.get(style_name), base) + cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style) + if entries: + cells = [(char, base[0], base[1], base[2]) for char in separator] + cells + if is_current: + current_at = total + (len(separator) if entries else 0) + entries.append(cells) + total += len(cells) + listing = [cell for cells in entries for cell in cells] + if len(listing) > avail: + listing = self.fit_list(listing, avail, current_at, base) + justify = opts.get("status-justify") + offset = 0 + if justify in ("centre", "center"): + offset = max(0, (avail - len(listing)) // 2) + elif justify == "right": + offset = max(0, avail - len(listing)) + canvas.put_cells(0, row, left) + canvas.put_cells(len(left) + offset, row, listing) + canvas.put_cells(client.cols - len(right), row, right) + + @staticmethod + def fit_list(cells, avail, current_at, base): + """The slice of the window list around the current window, with < and > markers.""" + if avail <= 0: + return [] + start = max(0, min(current_at - avail // 3, len(cells) - avail)) + piece = cells[start:start + avail] + marker = lambda char: (char, base[0], base[1], base[2] | 1) + if start > 0 and piece: + piece[0] = marker("<") + if start + avail < len(cells) and piece: + piece[-1] = marker(">") + return piece + + +class MplexCtx: + """What a command runs against: the issuing client and its current session, window, pane.""" + + def __init__(self, server, client=None, pane=None, interactive=False): + self.server = server + self.client = client + self.interactive = interactive + self.depth = 0 + self.pane = pane + self.window = pane.window if pane is not None else None + self.session = self.window.session if self.window is not None else None + if client is not None and client.session is not None and pane is None: + self.session = client.session + self.window = self.session.window() + self.pane = self.window.active if self.window is not None else None + elif self.session is None and client is not None and client.env_pane is not None: + found = server.panes.get(client.env_pane) + if found is not None and found.window is not None: + self.pane = found + self.window = found.window + self.session = found.window.session + if self.session is None: + self.session = server.latest_session() + if self.session is not None: + self.window = self.session.window() + self.pane = self.window.active if self.window is not None else None + + +class MplexTargets: + """tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session.""" + + @staticmethod + def session(ctx, spec): + server = ctx.server + if not spec: + if ctx.session is None: + raise MplexError("no current session") + return ctx.session + found = None + if spec.startswith("$") and spec[1:].isdigit(): + found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None) + else: + spec = spec.lstrip("=") + found = next((s for s in server.sessions.values() if s.name == spec), None) + if found is None: + matches = [s for s in server.sessions.values() if s.name.startswith(spec)] + if len(matches) == 1: + found = matches[0] + if found is None: + import fnmatch + matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)] + if len(matches) == 1: + found = matches[0] + if found is None: + raise MplexError("can't find session: %s" % spec) + return found + + @classmethod + def window(cls, ctx, spec): + """Returns (session, index, window).""" + server = ctx.server + spec = spec or "" + if spec.startswith("@") and spec[1:].isdigit(): + window = server.windows.get(int(spec[1:])) + if window is None: + raise MplexError("can't find window: %s" % spec) + return window.session, window.session.index_of(window), window + if ":" in spec: + sess_part, _sep, win_part = spec.partition(":") + session = cls.session(ctx, sess_part) + else: + sess_part, win_part, session = "", spec, ctx.session + if session is None: + raise MplexError("no current session") + index = cls.window_index(session, win_part) + if index is None and ":" not in spec and win_part: + try: + session = cls.session(ctx, win_part) + index = session.current + except MplexError: + index = None + if index is None or index not in session.windows: + raise MplexError("can't find window: %s" % (win_part or spec)) + return session, index, session.windows[index] + + @staticmethod + def window_index(session, text): + indexes = session.indexes() + if not indexes: + return None + text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text + if text == "": + return session.current + if text in ("!", "{last}"): + return session.last + if text in ("^", "{start}"): + return indexes[0] + if text in ("$", "{end}"): + return indexes[-1] + if text == "{next}": + text = "+" + if text == "{previous}": + text = "-" + if text[0] in "+-": + step = int(text[1:]) if text[1:].isdigit() else 1 + here = indexes.index(session.current) if session.current in indexes else 0 + return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)] + if text.isdigit(): + return int(text) + for index in indexes: + if session.windows[index].name == text: + return index + matches = [index for index in indexes if session.windows[index].name.startswith(text)] + if len(matches) == 1: + return matches[0] + import fnmatch + matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)] + return matches[0] if len(matches) == 1 else None + + @classmethod + def pane(cls, ctx, spec): + """Returns (session, window, pane).""" + server = ctx.server + spec = spec or "" + if spec.startswith("%") and spec[1:].isdigit(): + pane = server.panes.get(int(spec[1:])) + if pane is None or pane.window is None: + raise MplexError("can't find pane: %s" % spec) + return pane.window.session, pane.window, pane + if ":" in spec: + sess_part, _sep, rest = spec.partition(":") + if "." in rest: + win_part, _dot, pane_part = rest.rpartition(".") + else: + win_part, pane_part = rest, "" + ctx_for_window = sess_part + ":" + win_part + session, _index, window = cls.window(ctx, ctx_for_window) + elif "." in spec: + win_part, _dot, pane_part = spec.rpartition(".") + session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window) + else: + pane_part = spec + session, window = ctx.session, ctx.window + if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"): try: - signal.signal(signal.SIGWINCH, old_handler) - except (ValueError, OSError): - pass - self._restore_tty() - try: - self._log_fh.close() - except OSError: - pass - try: - self._server.close() - except OSError: - pass - try: - os.unlink(self.socket_path) - except OSError: - pass - if crash is not None: - try: - os.killpg(self.pid, signal.SIGKILL) - except OSError: - try: - os.kill(self.pid, signal.SIGKILL) - except OSError: - pass - sys.stderr.write("tai plexer: wrapper crashed (%s), terminating the session\n" % short_error(crash)) + session, _index, window = cls.window(ctx, pane_part) + pane_part = "" + except MplexError: + raise MplexError("can't find pane: %s" % spec) + if window is None: + raise MplexError("no current window") + panes = window.panes() + if not pane_part: + return session, window, window.active + base = window.options.get("pane-base-index") + if pane_part.isdigit(): + index = int(pane_part) - base + if 0 <= index < len(panes): + return session, window, panes[index] + raise MplexError("can't find pane: %s" % pane_part) + here = panes.index(window.active) if window.active in panes else 0 + if pane_part in ("!", "{last}"): + if window.last_pane in panes: + return session, window, window.last_pane + raise MplexError("can't find pane: %s" % pane_part) + if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"): + sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+" + amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1 + return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)] + edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height, + "{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width, + "{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width, + "{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0, + "{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width} + if pane_part in edges: + for pane in panes: + if edges[pane_part](pane): + return session, window, pane + raise MplexError("can't find pane: %s" % pane_part) + + +class MplexClientConn: + """Server-side state of one connection: framing, key table position, prompt, overlay, last frame.""" + + def __init__(self, server, sock, ident): + self.server = server + self.sock = sock + self.id = ident + self.inbuf = bytearray() + self.out = bytearray() + self.rows = 24 + self.cols = 80 + self.session = None + self.attached = False + self.table = "root" + self.repeating = False + self.repeat_until = 0.0 + self.keybuf = b"" + self.esc_deadline = None + self.prompt = None + self.message = None + self.overlay = None + self.prev = None + self.cursor = None + self.dirty = True + self.last_render = 0.0 + self.pid = 0 + self.tty = "" + self.term = "" + self.cwd = os.getcwd() + self.env = {} + self.env_pane = None + self.closing = False + self.created = time.time() + + def send(self, kind, payload=b""): + if len(self.out) > MPLEX_CLIENT_OUT_MAX: + self.prev = None + return False + self.out += kind + struct.pack(">I", len(payload)) + payload + return True + + def frames(self, data): + """Complete (kind, payload) frames in what arrived; None when the stream is corrupt.""" + self.inbuf += data + found = [] + while len(self.inbuf) >= 5: + length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0] + if length > MPLEX_FRAME_MAX: + return None + if len(self.inbuf) < 5 + length: + break + found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length]))) + del self.inbuf[:5 + length] + return found + + def redispatch(self, name, raw): + self.server.handle_key(self, name, raw) + + def flush(self): + if not self.out: + return try: - _, status = os.waitpid(self.pid, 0) - return os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else (1 if crash else 0) + sent = self.sock.send(bytes(self.out[:65536])) + del self.out[:sent] + except (BlockingIOError, InterruptedError): + pass except OSError: - return 1 if crash else 0 + self.out.clear() + self.closing = True - def _relay_loop(self): - """Every byte headed INTO the child goes through self._pending_to_child, - drained here in bounded chunks only when select() says master_fd is - writable -- never a direct, unbounded os.write(self.master_fd, ...) - anywhere, including from the keystroke path below. A real deadlock - was found and fixed here: a large plexer_send payload written - directly and synchronously into the pty (echo-enabled, so the write - itself generates output) filled the pty's buffer while the only - thing that could ever drain that output -- this very loop -- was - itself blocked inside that same write call. Queuing and draining - cooperatively, alongside reading master_fd's output on the same - select() pass, is what actually avoids that, not a bigger buffer.""" - stdin_fd = sys.stdin.fileno() - stdout_fd = sys.stdout.fileno() - pending_prefix = False - while True: - if self._resize_pending: - self._resize_pending = False - self._apply_winsize() - watch_read = [stdin_fd, self.master_fd, self._server.fileno()] - watch_write = [self.master_fd] if self._pending_to_child else [] + def close(self): + try: + self.sock.close() + except OSError: + pass + + +class MplexServer: + """Owns every session, window, pane and client. One select loop, no threads.""" + + def __init__(self, home): + self.home = home + self.dir = plexer_dir(home) + self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME) + self.config = MplexConfig.load(MplexConfig.default_path(home)) + self.options = self.config.options + self.sessions = {} + self.windows = {} + self.panes = {} + self.clients = [] + self.conns = [] + self.buffers = [] + self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX) + self.prompt_history = {} + self.timers = [] + self.reaping = [] + self.marked = None + self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0} + self.had_session = False + self.running = False + self.listener = None + self.renderer = MplexRenderer(self) + self.commands = MplexCommands(self) + self.shell_cache = {} + self.started = time.monotonic() + self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + self.next_tick = 0.0 + self.notice = None + for problem in self.config.errors: + self.log_message("mplex.toml: " + problem) + if self.config.skipped: + self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped + + # ---- bookkeeping ----------------------------------------------------- + + def next_id(self, kind): + value = self.ids[kind] + self.ids[kind] += 1 + return value + + def log_message(self, text): + self.messages.append((time.time(), text)) + + def latest_session(self): + if not self.sessions: + return None + return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id)) + + def attached_clients(self, session=None): + return [c for c in self.clients if c.attached and (session is None or c.session is session)] + + def pane_options(self, pane): + return pane.window.options if pane.window is not None else self.options + + def message(self, client, text, milliseconds=None): + self.log_message(text) + if client is None: + return + if milliseconds is None: + milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time") + client.message = (text, time.monotonic() + milliseconds / 1000.0) + client.dirty = True + self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True)) + + def mark_dirty(self, window=None): + for client in self.clients: + if client.attached and (window is None or client.session.window() is window): + client.dirty = True + + def mark_all_dirty(self): + for client in self.clients: + client.dirty = True + + def add_timer(self, deadline, callback): + if len(self.timers) < 1024: + self.timers.append((deadline, callback)) + + # ---- buffers and copy ------------------------------------------------ + + def add_buffer(self, text, name=None): + if name is None: + name = "buffer%d" % self.next_id("buffer") + self.buffers = [item for item in self.buffers if item["name"] != name] + self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]}) + del self.buffers[self.options.get("buffer-limit"):] + return name + + def top_buffer_text(self): + return self.buffers[0]["data"] if self.buffers else "" + + def find_buffer(self, name): + if not name: + if not self.buffers: + raise MplexError("no buffers") + return self.buffers[0] + for item in self.buffers: + if item["name"] == name: + return item + raise MplexError("unknown buffer: %s" % name) + + def run_shell_input(self, command, text): + try: + done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT) + if done.returncode != 0: + raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200]) + except (OSError, subprocess.SubprocessError) as exc: + raise MplexError("copy command failed: " + short_error(exc)) + + def store_copy(self, text, command="", append=False): + if append and self.buffers: + self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX] + text = self.buffers[0]["data"] + else: + self.add_buffer(text) + pipe = command or self.options.get("copy-command") + if pipe: + self.run_shell_input(pipe, text) + if self.options.get("set-clipboard") != "off": + sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07" + for client in self.attached_clients(): + client.send(b"O", sequence.encode("ascii")) + + # ---- sizing ---------------------------------------------------------- + + def resize_session(self, session): + sizes = [(c.cols, c.rows) for c in self.attached_clients(session)] + if sizes: + session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes)) + elif not hasattr(session, "size"): + session.size = (80, 24) + cols, rows = session.size + for window in session.windows.values(): + window.width = max(1, cols) + window.height = max(1, rows - (1 if window.options.get("status") else 0)) + window.relayout() + self.mark_dirty() + + def resize_all(self): + for session in self.sessions.values(): + self.resize_session(session) + + # ---- model: panes, windows, sessions --------------------------------- + + def default_shell(self, options): + return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh" + + def resolve_term(self, name): + wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"] + roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"] + for term in wanted: + for root in roots: + if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))): + return term + return "xterm" + + def spawn_pane(self, window, width, height, words, cwd, env_extra=None): + if len(self.panes) >= MPLEX_MAX_PANES: + raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES) + session = window.session + options = window.options + pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp) + shell = self.default_shell(options) + if words: + file = shell if len(words) == 1 else words[0] + argv = [shell, "-c", words[0]] if len(words) == 1 else list(words) + elif options.get("default-command"): + file, argv = shell, [shell, "-c", options.get("default-command")] + else: + file, argv = shell, ["-" + os.path.basename(shell)] + env = dict(session.env) + env.pop("LINES", None) + env.pop("COLUMNS", None) + env["TERM"] = self.resolve_term(options.get("default-terminal")) + env[PLEXER_ACTIVE_ENV] = "1" + env[PLEXER_SOCKET_ENV] = self.sock_path + env[PLEXER_PANE_ENV] = str(pane.id) + env.update(env_extra or {}) + pane.window = window + pane.spawn(file, argv, cwd or session.cwd, env) + self.panes[pane.id] = pane + return pane + + def register_window(self, session, window, index, select): + window.session = session + window.options.parent = session.options + session.windows[index] = window + self.windows[window.id] = window + if select or session.current is None: + session.select(index) + + def create_window(self, session, name, cwd, words, env_extra, index, select): + if len(self.windows) >= MPLEX_MAX_WINDOWS: + raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS) + cols, rows = getattr(session, "size", (80, 24)) + window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options) + window.session = session + pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra) + window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height) + window.active = pane + pane.last_active = time.monotonic() + window.auto_rename = not name + if not name: + window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0]) + self.register_window(session, window, index, select) + window.relayout() + self.mark_dirty() + return window + + def create_session(self, name, cwd, env, words, window_name, size, index_hint=None): + if len(self.sessions) >= MPLEX_MAX_SESSIONS: + raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS) + session = MplexSession(self.next_id("session"), name, self.options, cwd, env) + session.size = size + self.sessions[session.id] = session + self.had_session = True + try: + self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True) + except Exception: + self.sessions.pop(session.id, None) + raise + return session + + def auto_session_name(self): + taken = {s.name for s in self.sessions.values()} + number = 0 + while str(number) in taken: + number += 1 + return str(number) + + def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select): + leaf = window.leaf_of(target) + if leaf is None: + raise MplexError("pane is not in this window") + if window.zoomed is not None: + window.zoomed = None + window.relayout() + leaf = window.leaf_of(target) + span = leaf.w if kind == "lr" else leaf.h + if span < 3: + raise MplexError("no space for new pane") + pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra) + try: + window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size) + except MplexError: + self.discard_pane(pane) + raise + window.layout_index = -1 + window.relayout() + if select: + window.set_active(pane) + self.mark_dirty(window) + return pane + + def discard_pane(self, pane): + pane.close() + self.panes.pop(pane.id, None) + if pane.pid is not None: + self.reaping.append((pane.pid, time.monotonic())) + pane.pid = None + + def select_pane(self, window, pane): + if window.zoomed is not None and pane is not window.zoomed: + window.zoomed = None + window.relayout() + window.set_active(pane) + self.mark_dirty(window) + + def kill_pane(self, pane): + window = pane.window + if window is None: + return + if len(window.panes()) == 1: + self.kill_window(window) + return + was_active = window.active is pane + window.root = MplexLayout.remove(window.root, window.leaf_of(pane)) + if window.zoomed is pane: + window.zoomed = None + if self.marked is pane: + self.marked = None + self.discard_pane(pane) + rest = window.panes() + if window.last_pane is pane: + window.last_pane = None + if was_active: + window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active) + window.last_pane = None + window.layout_index = -1 + window.relayout() + self.mark_dirty(window) + + def kill_window(self, window): + session = window.session + index = session.index_of(window) + for pane in window.panes(): + if self.marked is pane: + self.marked = None + self.discard_pane(pane) + self.windows.pop(window.id, None) + if index is not None: + session.windows.pop(index, None) + if not session.windows: + self.kill_session(session) + return + session.history = [item for item in session.history if item is not window] + if session.current == index: + nxt = session.last + if nxt is None: + lower = [i for i in session.indexes() if i < index] + nxt = lower[-1] if lower else session.indexes()[-1] + session.current = nxt + session.history = [item for item in session.history if item is not session.windows[nxt]] + if session.options.get("renumber-windows"): + self.renumber(session) + self.mark_dirty() + + def renumber(self, session): + base = self.options.get("base-index") + current = session.window() + ordered = [session.windows[i] for i in session.indexes()] + session.windows = {base + n: window for n, window in enumerate(ordered)} + session.current = session.index_of(current) if current else None + + def kill_session(self, session): + for window in list(session.windows.values()): + for pane in window.panes(): + if self.marked is pane: + self.marked = None + self.discard_pane(pane) + self.windows.pop(window.id, None) + session.windows.clear() + self.sessions.pop(session.id, None) + for client in list(self.attached_clients(session)): + others = [s for s in self.sessions.values()] + if others and not self.options.get("detach-on-destroy"): + self.attach_client(client, self.latest_session()) + else: + self.detach_client(client, "[exited]", b"X") + self.mark_dirty() + + def attach_client(self, client, session): + old = client.session + client.session = session + session.last_attached = time.time() + client.table = "root" + client.prompt = None + client.overlay = None + client.prev = None + if not client.attached: + client.attached = True + client.send(b"A") + if old is not None and old is not session: + self.resize_session(old) + self.resize_session(session) + client.dirty = True + if self.notice: + self.message(client, self.notice, 4000) + self.notice = None + + def detach_client(self, client, text="", kind=b"D"): + old = client.session + client.send(kind, text.encode("utf-8")) + client.attached = False + client.session = None + client.closing = True + if old is not None and old.id in self.sessions: + self.resize_session(old) + + # ---- formats --------------------------------------------------------- + + def window_flags(self, session, window): + flags = "" + index = session.index_of(window) + if index == session.current: + flags += "*" + elif index == session.last: + flags += "-" + if window.zoomed is not None: + flags += "Z" + if self.marked is not None and self.marked.window is window: + flags += "M" + return flags + + def resolver_for(self, session, window, pane, client=None): + server = self + + def get(name): + if name.startswith("("): + return server.shell_output(name[1:]) + return server.variable(name, session, window, pane, client) + + return get + + def shell_output(self, command): + now = time.monotonic() + cached = self.shell_cache.get(command) + interval = max(1, self.options.get("status-interval")) + if cached and now - cached[0] < interval: + return cached[1] + try: + done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT) + text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0] + except (OSError, subprocess.SubprocessError): + text = "" + if len(self.shell_cache) > 64: + self.shell_cache.clear() + self.shell_cache[command] = (now, text) + return text + + def variable(self, name, session, window, pane, client): + if name in MplexOptions.DEFS: + scope = window.options if window is not None else (session.options if session is not None else self.options) + value = scope.get(name) + if isinstance(value, bool): + return "1" if value else "0" + return str(value) + if name == "host": + return socket.gethostname() + if name == "host_short": + return socket.gethostname().split(".")[0] + if name == "version": + return VERSION + if session is not None: + if name == "session_name": + return session.name + if name == "session_id": + return session.tag + if name == "session_windows": + return str(len(session.windows)) + if name == "session_created": + return str(int(session.created)) + if name == "session_attached": + return str(len(self.attached_clients(session))) + if window is not None and session is not None: + index = session.index_of(window) + if name == "window_index": + return str(index) + if name == "window_name": + return window.name + if name == "window_id": + return window.tag + if name == "window_flags": + return self.window_flags(session, window) + if name == "window_active": + return "1" if index == session.current else "0" + if name == "window_last_flag": + return "1" if index == session.last else "0" + if name == "window_panes": + return str(len(window.panes())) + if name == "window_zoomed_flag": + return "1" if window.zoomed is not None else "0" + if name == "window_width": + return str(window.width) + if name == "window_height": + return str(window.height) + if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"): + return "0" + if name == "window_marked_flag": + return "1" if self.marked is not None and self.marked.window is window else "0" + if pane is not None: + mode = pane.top_mode() + screen = pane.screen + if name == "pane_id": + return pane.tag + if name == "pane_index": + return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0" + if name == "pane_title": + return pane.title + if name == "pane_current_command": + return pane.foreground_command() + if name == "pane_current_path": + return pane.current_path() + if name == "pane_pid": + return str(pane.pid or "") + if name == "pane_width": + return str(pane.w) + if name == "pane_height": + return str(pane.h) + if name == "pane_active": + return "1" if pane.window is not None and pane.window.active is pane else "0" + if name == "pane_last": + return "1" if pane.window is not None and pane.window.last_pane is pane else "0" + if name == "pane_in_mode": + return "1" if mode is not None else "0" + if name == "pane_mode": + return mode.name if mode is not None else "" + if name == "pane_dead": + return "1" if pane.dead else "0" + if name == "pane_dead_status": + return "" if pane.exit_status is None else str(pane.exit_status) + if name == "pane_left": + return str(pane.x) + if name == "pane_top": + return str(pane.y) + if name == "pane_right": + return str(pane.x + pane.w - 1) + if name == "pane_bottom": + return str(pane.y + pane.h - 1) + if name == "pane_synchronized": + return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0" + if name == "pane_marked": + return "1" if self.marked is pane else "0" + if name == "pane_marked_set": + return "1" if self.marked is not None else "0" + if name == "alternate_on": + return "1" if screen.alt_active else "0" + if name == "history_size": + return str(pane.history_size()) + if name == "history_limit": + return str(screen.history.maxlen if screen.history is not None else 0) + if name == "cursor_x": + return str(screen.cx) + if name == "cursor_y": + return str(screen.cy) + if name == "pane_search_string": + return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else "" + if isinstance(mode, MplexCopyMode): + if name == "scroll_position": + return str(mode.oy) + if name == "copy_cursor_x": + return str(mode.cx) + if name == "copy_cursor_y": + return str(mode.cy) + if name == "selection_present": + return "1" if mode.anchor is not None else "0" + if name == "copy_cursor_line": + return mode.text(mode.abs_y()) + if name == "copy_cursor_word": + line = mode.text(mode.abs_y()) + if not line: + return "" + x = min(mode.cx, len(line) - 1) + seps = pane.window.options.get("word-separators") + " " + start = end = x + while start > 0 and line[start - 1] not in seps: + start -= 1 + while end + 1 < len(line) and line[end + 1] not in seps: + end += 1 + return line[start:end + 1] + if name == "scroll_position": + return "" + if client is not None: + if name == "client_width": + return str(client.cols) + if name == "client_height": + return str(client.rows) + if name == "client_session": + return client.session.name if client.session else "" + if name == "client_tty": + return client.tty + if name == "client_termname": + return client.term + if name == "client_pid": + return str(client.pid) + if name == "client_key_table": + return client.table + if name == "client_prefix": + return "1" if client.table == "prefix" else "0" + if name == "client_created": + return str(int(client.created)) + return None + + def expand(self, text, ctx, extra=None): + """Format-expand text for a command context (strftime applied).""" + base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client) + if extra: + def resolve(name): + return extra[name] if name in extra else base(name) + else: + resolve = base + return MplexFormat.with_time(MplexFormat.expand(text, resolve)) + + # ---- key handling ---------------------------------------------------- + + def is_prefix(self, session, name): + opts = session.options + return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None" + + def input_bytes(self, client, data): + client.keybuf = (client.keybuf + data)[-65536:] + self.process_keys(client, False) + + def process_keys(self, client, final): + tokens, rest = MplexKeys.split(client.keybuf, final) + client.keybuf = rest + for name, raw in tokens: + if not client.attached: + break + self.handle_key(client, name, raw) + if client.keybuf and client.attached: + client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0 + else: + client.esc_deadline = None + + def handle_key(self, client, name, raw): + if not client.attached: + return + client.dirty = True + if client.prompt is not None: + client.prompt.key(self, client, name, raw) + return + if client.overlay is not None: + client.overlay.key(name, raw, client) + return + client.message = None + session = client.session + window = session.window() + pane = window.active + if not name: + self.forward(pane, window, raw) + return + table = client.table + if table == "root": + binding = self.config.lookup("root", name) + if binding is not None: + self.run_binding(client, binding, pane) + return + if self.is_prefix(session, name): + client.table = "prefix" + client.repeating = False + return + mode = pane.top_mode() + if mode is not None: + mode.key(name, raw, client) + return + self.forward(pane, window, raw) + return + binding = self.config.lookup(table, name) + if client.repeating and not (binding is not None and binding.repeat): + client.table = "root" + client.repeating = False + self.handle_key(client, name, raw) + return + client.table = "root" + client.repeating = False + if binding is not None: + self.run_binding(client, binding, pane) + if binding.repeat and client.attached and client.table == "root": + client.table = table + client.repeating = True + client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0 + + def forward(self, pane, window, raw): + data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw + targets = window.panes() if window.options.get("synchronize-panes") else [pane] + for target in targets: + if not target.dead: + target.write(data) + target.input_bytes += len(data) + + def run_binding(self, client, binding, pane=None): + self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command) + + def run_checked(self, ctx, text): + try: + output = self.run_line(ctx, text) + if output: + self.show_output(ctx, output) + except MplexError as exc: + self.message(ctx.client, str(exc)) + except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down + import traceback + sys.stderr.write(traceback.format_exc()) + self.message(ctx.client, "internal error: " + short_error(exc)) + + def show_output(self, ctx, text): + pane = ctx.pane + lines = text.rstrip("\n").split("\n") + if pane is None or (len(lines) == 1 and len(lines[0]) < 200): + self.message(ctx.client, lines[0] if lines else "") + return + old = pane.find_mode(MplexCopyMode) + if old is not None and old.static_rows is not None: + old.close() + pane.push_mode(MplexCopyMode(self, pane, lines=lines)) + self.mark_dirty(pane.window) + + def ask_confirm(self, client, text, callback): + def done(value): + callback() + client.prompt = MplexPrompt(text + " ", "", done, confirm=True) + client.dirty = True + + def refresh_choosers(self): + for pane in list(self.panes.values()): + for mode in list(pane.modes): + if isinstance(mode, MplexChooser): + mode.rebuild() + self.mark_dirty() + + # ---- command execution ----------------------------------------------- + + def run_line(self, ctx, text, depth=0): + return self.run_commands(ctx, MplexCommandParser.parse(text), depth) + + def run_commands(self, ctx, commands, depth=0): + if depth > MPLEX_COMMAND_DEPTH_MAX: + raise MplexError("commands nested too deeply") + flat = [] + for words in commands: + piece = [] + for word in words: + if word == ";": + if piece: + flat.append(piece) + piece = [] + else: + piece.append(word) + if piece: + flat.append(piece) + output = [] + for words in flat: + text = self.run_one(ctx, words, depth) + if text: + output.append(text) + return "\n".join(output) + + def run_words(self, ctx, words): + return self.run_commands(ctx, [words], 0) + + def run_one(self, ctx, words, depth): + name = MplexCommands.resolve(words[0]) + aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] + args = MplexArgs.parse(name, spec, words[1:], low, high) + ctx.depth = depth + handler = getattr(self.commands, "cmd_" + name.replace("-", "_")) + return handler(ctx, args) or "" + + # ---- sockets --------------------------------------------------------- + + def open_listener(self): + lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(lock_fd) + return None + self.lock_fd = lock_fd + try: + os.unlink(self.sock_path) + except OSError: + pass + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self.sock_path) + os.chmod(self.sock_path, 0o600) + listener.listen(16) + listener.setblocking(False) + return listener + + def accept(self): + try: + sock, _addr = self.listener.accept() + except OSError: + return + sock.setblocking(False) + if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2: + sock.close() + return + self.conns.append(MplexPending(sock)) + + def pending_read(self, pending): + try: + data = pending.sock.recv(65536) + except (BlockingIOError, InterruptedError): + return + except OSError: + data = b"" + if not data: + self.drop_pending(pending) + return + pending.buf += data + if pending.buf[:1] == MPLEX_MAGIC: + if len(self.clients) >= MPLEX_MAX_CLIENTS: + self.drop_pending(pending) + return + self.conns.remove(pending) + client = MplexClientConn(self, pending.sock, self.next_id("client")) + self.clients.append(client) + self.client_data(client, bytes(pending.buf[1:])) + return + if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX: + self.control(pending) + + def drop_pending(self, pending): + if pending in self.conns: + self.conns.remove(pending) + try: + pending.sock.close() + except OSError: + pass + + def control(self, pending): + """Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController).""" + try: + request = json.loads(bytes(pending.buf).decode("utf-8", "replace")) + if not isinstance(request, dict): + raise ValueError("request must be a JSON object") + reply = self.dispatch_control(request) + except Exception as exc: # noqa: BLE001 -- one bad request must not end the session + reply = {"ok": False, "error": short_error(exc)} + try: + pending.sock.setblocking(True) + pending.sock.settimeout(2) + pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8")) + except OSError: + pass + self.drop_pending(pending) + + def control_pane(self, request): + ident = request.get("pane") + if ident not in (None, ""): + pane = self.panes.get(int(str(ident).lstrip("%"))) + if pane is None: + raise ValueError("no such pane: %s" % ident) + return pane + session = self.latest_session() + if session is None or session.window() is None: + raise ValueError("no session") + return session.window().active + + def dispatch_control(self, request): + cmd = request.get("cmd") + if cmd == "send": + pane = self.control_pane(request) + text = str(request.get("text") or "") + pane.write(text.encode("utf-8", "replace")) + if request.get("enter"): + pane.write(b"\r") + return {"ok": True, "data": True} + if cmd == "read": + pane = self.control_pane(request) + return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))} + if cmd == "status": + pane = self.control_pane(request) + window = pane.window + return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path, + "pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}} + return {"ok": False, "error": "unknown command %r" % cmd} + + def client_read(self, client): + try: + data = client.sock.recv(65536) + except (BlockingIOError, InterruptedError): + return + except OSError: + data = b"" + if not data: + self.drop_client(client) + return + self.client_data(client, data) + + def client_data(self, client, data): + frames = client.frames(data) + if frames is None: + self.drop_client(client) + return + for kind, payload in frames: + if kind == b"H": + self.on_hello(client, payload) + elif kind == b"I" and client.attached: + self.input_bytes(client, payload) + elif kind == b"R": + try: + size = json.loads(payload.decode("utf-8", "replace")) + client.rows = max(2, min(1000, int(size["rows"]))) + client.cols = max(2, min(2000, int(size["cols"]))) + except (ValueError, KeyError, TypeError): + continue + client.prev = None + if client.attached: + self.resize_session(client.session) + client.dirty = True + + def on_hello(self, client, payload): + try: + info = json.loads(payload.decode("utf-8", "replace")) + if not isinstance(info, dict): + raise ValueError("hello must be an object") + client.rows = max(2, min(1000, int(info.get("rows") or 24))) + client.cols = max(2, min(2000, int(info.get("cols") or 80))) + client.term = str(info.get("term") or "")[:64] + client.tty = str(info.get("tty") or "")[:128] + client.cwd = str(info.get("cwd") or "/")[:4096] + client.pid = int(info.get("pid") or 0) + env = info.get("env") or {} + client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {} + pane = info.get("pane") + client.env_pane = int(pane) if pane not in (None, "") else None + commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item] + except (ValueError, TypeError) as exc: + client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8")) + client.closing = True + return + ctx = MplexCtx(self, client=client) + try: + output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text) + except MplexError as exc: + client.send(b"E", str(exc).encode("utf-8")) + client.closing = True + return + except Exception as exc: # noqa: BLE001 + import traceback + sys.stderr.write(traceback.format_exc()) + client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8")) + client.closing = True + return + if not client.attached: + if output: + client.send(b"M", output.encode("utf-8")) + client.send(b"X", b"") + client.closing = True + + def drop_client(self, client): + if client in self.clients: + self.clients.remove(client) + session = client.session + client.attached = False + client.session = None + client.close() + if session is not None and session.id in self.sessions: + self.resize_session(session) + + # ---- main loop ------------------------------------------------------- + + def serve(self): + self.listener = self.open_listener() + if self.listener is None: + return 1 + signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False)) + signal.signal(signal.SIGHUP, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + self.running = True + try: + self.loop() + finally: + self.shutdown() + return 0 + + def stop(self): + self.running = False + + def loop(self): + 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: - readable, writable, _ = select.select(watch_read, watch_write, [], 1.0) + ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now)) except InterruptedError: continue - if not readable and not writable and not self._process_alive(): - break - if self._server.fileno() in readable: + 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 + + def clients_busy(self): + return any(client.out for client in self.clients) + + def sweep_bad_fds(self): + for pending in list(self.conns): + if pending.sock.fileno() < 0: + self.conns.remove(pending) + for client in list(self.clients): + if client.sock.fileno() < 0: + self.drop_client(client) + for pane in list(self.panes.values()): + if pane.master_fd is not None: try: - conn, _addr = self._server.accept() - self._handle_control(conn) + os.fstat(pane.master_fd) + except OSError: + self.pane_exited(pane) + + def wakeup(self, now): + due = [now + 1.0] + due.extend(deadline for deadline, _cb in self.timers) + for client in self.clients: + if client.esc_deadline: + due.append(client.esc_deadline) + if client.repeating: + due.append(client.repeat_until) + if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4: + due.append(client.last_render + MPLEX_RENDER_INTERVAL) + if client.overlay is not None and hasattr(client.overlay, "until"): + due.append(client.overlay.until) + return max(0.0, min(due) - now) + + def run_deadlines(self, now): + if self.timers: + ripe = [item for item in self.timers if item[0] <= now] + self.timers = [item for item in self.timers if item[0] > now] + for _deadline, callback in ripe: + callback() + for client in list(self.clients): + if client.esc_deadline and now >= client.esc_deadline and client.attached: + client.esc_deadline = None + self.process_keys(client, True) + if client.repeating and now >= client.repeat_until: + client.repeating = False + client.table = "root" + client.dirty = True + overlay = client.overlay + if overlay is not None and hasattr(overlay, "until") and now >= overlay.until: + client.overlay = None + client.dirty = True + if now >= self.next_tick: + self.next_tick = now + 1.0 + self.tick(now) + for pending in list(self.conns): + if now - pending.created > 5.0: + self.drop_pending(pending) + + def tick(self, now): + for window in list(self.windows.values()): + if window.auto_rename and window.active is not None and window.session is not None: + template = window.options.get("automatic-rename-format") + if window.options.get("automatic-rename"): + resolver = self.resolver_for(window.session, window, window.active) + name = MplexFormat.expand(template, resolver) + if name and name != window.name: + window.name = name[:64] + self.mark_all_dirty() + for pane in window.panes(): + if isinstance(pane.top_mode(), MplexClockMode): + self.mark_dirty(window) + interval = self.options.get("status-interval") + if interval and int(now) % interval == 0: + self.mark_all_dirty() + + def pane_read(self, pane): + try: + data = os.read(pane.master_fd, 65536) + except (BlockingIOError, InterruptedError): + return + except OSError: + data = b"" + if not data: + self.pane_exited(pane) + return + pane.feed(data) + self.mark_dirty(pane.window) + + def pane_exited(self, pane): + status = None + if pane.pid is not None: + try: + done, raw = os.waitpid(pane.pid, os.WNOHANG) + if done: + status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw + pane.pid = None + except OSError: + pane.pid = None + if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead: + pane.dead = True + pane.exit_status = status + pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8")) + if pane.master_fd is not None: + try: + os.close(pane.master_fd) except OSError: pass - if self.master_fd in writable and self._pending_to_child: - try: - written = os.write(self.master_fd, bytes(self._pending_to_child[:4096])) - del self._pending_to_child[:written] - except OSError: - self._pending_to_child = bytearray() - if self.master_fd in readable: - try: - data = os.read(self.master_fd, 65536) - except OSError: - data = b"" - if not data: - break - self._log_fh.write(data) - os.write(stdout_fd, data) - if stdin_fd in readable: - try: - data = os.read(stdin_fd, 4096) - except OSError: - data = b"" - if not data: - break - data, pending_prefix = self._strip_prefix_commands(data, pending_prefix) - if data: - self._pending_to_child += data - if not self._process_alive(): - break + pane.master_fd = None + self.mark_dirty(pane.window) + return + if pane.window is not None and pane.id in self.panes: + self.kill_pane(pane) - def _strip_prefix_commands(self, data, pending_prefix): - """Intercepts tai's own Ctrl+A shortcuts; everything else passes through untouched. - - 'q' quits the wrapped session. Verified empirically (see - tests/plexer.py) that in this environment an interactive bash running - as a pty.fork() session leader does not die from an external SIGHUP - OR SIGTERM -- confirmed independent of this class with a bare - pty.fork()+bash+os.kill script, so this is not something tai is - doing wrong, it is this environment's actual bash behavior. SIGKILL - is the one signal no process can ignore, so that's what's used here. - This is a clean kill, not a true tmux-style detach-and-reattach -- - there is no session persistence to reattach to yet, so it is named - and documented as "quit," not "detach", to not promise what isn't built.""" - out = bytearray() - i = 0 - while i < len(data): - byte = data[i:i + 1] - if pending_prefix: - pending_prefix = False - if byte == b"q": - # killpg, not kill: pty.fork()'s child is its own session - # and process-group leader (pid == pgid), so this reaches - # a plain foreground child the bare shell pid alone would - # miss. It does NOT reach every descendant, and verified - # empirically rather than assumed: an interactive bash's - # own job control puts each backgrounded job (`sleep &`) - # in a NEW process group of its own, specifically so - # signals to the shell's group -- Ctrl+C, this killpg, - # closing a real terminal -- don't also take down its - # background jobs. A backgrounded job surviving `quit` is - # therefore expected, standard shell behavior, identical - # to what closing a real terminal does, not a gap to - # close here. + def reap_children(self, now): + keep = [] + for pid, since in self.reaping: + try: + done, _status = os.waitpid(pid, os.WNOHANG) + except OSError: + continue + if done: + continue + if now - since > MPLEX_REAP_KILL_AFTER: + try: + os.killpg(pid, signal.SIGKILL) + except OSError: try: - os.killpg(self.pid, signal.SIGKILL) + os.kill(pid, signal.SIGKILL) except OSError: - os.kill(self.pid, signal.SIGKILL) - elif byte == PLEXER_PREFIX_KEY: - out += PLEXER_PREFIX_KEY - i += 1 - continue - if byte == PLEXER_PREFIX_KEY: - pending_prefix = True - i += 1 - continue - out += byte - i += 1 - return bytes(out), pending_prefix + pass + keep.append((pid, since)) + self.reaping = keep - def _process_alive(self): + def render_clients(self, now): + for client in self.clients: + if not client.attached or not client.dirty: + continue + if now - client.last_render < MPLEX_RENDER_INTERVAL: + continue + if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4: + continue + canvas = self.renderer.frame(client) + data, snapshot = canvas.encode(client.prev) + client.last_render = now + client.dirty = False + if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor: + continue + client.prev = snapshot + client.cursor = canvas.cursor + client.send(b"O", data) + + def close_finished(self): + for client in list(self.clients): + if client.closing and not client.out: + self.drop_client(client) + + def shutdown(self): + for client in list(self.clients): + if client.attached: + client.send(b"X", b"[server exited]") + for _try in range(20): + if not client.out: + break + client.flush() + if client.out: + time.sleep(0.005) + client.close() + self.clients = [] + for pane in list(self.panes.values()): + pane.close(hard=True) + if pane.pid is not None: + try: + os.waitpid(pane.pid, os.WNOHANG) + except OSError: + pass + for item in (self.listener,): + try: + item.close() + except (OSError, AttributeError): + pass try: - pid, _status = os.waitpid(self.pid, os.WNOHANG) + os.unlink(self.sock_path) + except OSError: + pass + + @staticmethod + def probe(path): + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(1) + try: + probe.connect(path) + return True except OSError: return False - return pid == 0 + finally: + probe.close() + + @classmethod + def ensure_running(cls, home, wait=5.0): + """Start the server as a detached daemon unless one already answers on its socket.""" + path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) + if cls.probe(path): + return True + pid = os.fork() + if pid == 0: + code = 1 + try: + os.setsid() + if os.fork() > 0: + os._exit(0) + devnull = os.open(os.devnull, os.O_RDWR) + log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + os.dup2(devnull, 0) + os.dup2(log, 1) + os.dup2(log, 2) + os.chdir("/") + code = cls(home).serve() + except BaseException: # noqa: BLE001 + import traceback + traceback.print_exc() + os._exit(code) + os.waitpid(pid, 0) + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + if cls.probe(path): + return True + time.sleep(0.05) + return False + + +class MplexPending: + """A connection that has not yet said whether it speaks frames or one JSON line.""" + + def __init__(self, sock): + self.sock = sock + self.buf = bytearray() + self.created = time.monotonic() + + +# name: (aliases, getopt spec, min positional, max positional) +MPLEX_COMMAND_SPECS = { + "attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0), + "bind-key": (("bind",), "nrT:N:", 1, None), + "break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0), + "capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0), + "choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1), + "choose-client": ((), "NrZF:f:K:O:t:", 0, 1), + "choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1), + "clear-history": (("clearhist",), "Ht:", 0, 0), + "clock-mode": ((), "t:", 0, 0), + "command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1), + "confirm-before": (("confirm",), "byc:p:t:", 1, 1), + "copy-mode": ((), "deHMqSus:t:", 0, 0), + "delete-buffer": (("deleteb",), "b:", 0, 0), + "detach-client": (("detach",), "aPE:s:t:", 0, 0), + "display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1), + "display-panes": (("displayp",), "bNd:t:", 0, 1), + "find-window": (("findw",), "CiNrTZt:", 1, 1), + "has-session": (("has",), "t:", 0, 0), + "if-shell": (("if",), "bFt:", 2, 3), + "kill-pane": (("killp",), "at:", 0, 0), + "kill-server": ((), "", 0, 0), + "kill-session": ((), "aCt:", 0, 0), + "kill-window": (("killw",), "at:", 0, 0), + "last-pane": (("lastp",), "deZt:", 0, 0), + "last-window": (("last",), "t:", 0, 0), + "list-buffers": (("lsb",), "F:f:", 0, 0), + "list-clients": (("lsc",), "F:f:t:", 0, 0), + "list-commands": (("lscm",), "F:", 0, 1), + "list-keys": (("lsk",), "1aNP:T:", 0, 1), + "list-panes": (("lsp",), "asF:f:t:", 0, 0), + "list-sessions": (("ls",), "F:f:", 0, 0), + "list-windows": (("lsw",), "aF:f:t:", 0, 0), + "load-buffer": (("loadb",), "b:t:", 1, 1), + "move-window": (("movew",), "abdkrs:t:", 0, 0), + "new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None), + "new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None), + "next-layout": (("nextl",), "t:", 0, 0), + "next-window": (("next",), "at:", 0, 0), + "paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0), + "previous-layout": (("prevl",), "t:", 0, 0), + "previous-window": (("prev",), "at:", 0, 0), + "refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1), + "rename-session": (("rename",), "t:", 1, 1), + "rename-window": (("renamew",), "t:", 1, 1), + "resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1), + "rotate-window": (("rotatew",), "DUZt:", 0, 0), + "run-shell": (("run",), "bCEc:d:t:", 0, 1), + "save-buffer": (("saveb",), "ab:", 1, 1), + "select-layout": (("selectl",), "Enopt:", 0, 1), + "select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0), + "select-window": (("selectw",), "lnpTt:", 0, 0), + "send-keys": (("send",), "FHKlMRXc:N:t:", 0, None), + "send-prefix": ((), "2t:", 0, 0), + "set-buffer": (("setb",), "awb:n:t:", 0, 1), + "set-option": (("set",), "aFgopqsuUwt:", 1, 2), + "set-window-option": (("setw",), "aFgoqut:", 1, 2), + "show-buffer": (("showb",), "b:", 0, 0), + "show-messages": (("showmsgs",), "JTt:", 0, 0), + "show-options": (("show",), "AgHpqsvwt:", 0, 1), + "show-window-options": (("showw",), "gvt:", 0, 1), + "source-file": (("source",), "Fnqvt:", 1, None), + "split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None), + "start-server": (("start",), "", 0, 0), + "suspend-client": (("suspendc",), "t:", 0, 0), + "swap-pane": (("swapp",), "dDUZs:t:", 0, 0), + "swap-window": (("swapw",), "ds:t:", 0, 0), + "switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0), + "unbind-key": (("unbind",), "anqT:", 0, 1), +} + +MPLEX_DEFAULT_FORMATS = { + "list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}", + "list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}", + "list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}", + "list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]", + "list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"", + "display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)", +} + + +class MplexCommands: + """tmux's command set as methods named cmd_(ctx, args).""" + + def __init__(self, server): + self.s = server + + @staticmethod + def resolve(word): + if word in MPLEX_COMMAND_SPECS: + return word + for name, spec in MPLEX_COMMAND_SPECS.items(): + if word in spec[0]: + return name + if word in MPLEX_UNSUPPORTED: + raise MplexError("command not supported by mplex: %s" % word) + matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5]))) + raise MplexError("unknown command: %s" % word) + + # ---- helpers --------------------------------------------------------- + + def need_client(self, ctx): + if ctx.client is None or not ctx.client.attached: + raise MplexError("no attached client") + return ctx.client + + def pane_target(self, ctx, args, flag="t"): + return MplexTargets.pane(ctx, args.get(flag)) + + def window_target(self, ctx, args, flag="t"): + return MplexTargets.window(ctx, args.get(flag)) + + def session_target(self, ctx, args, flag="t"): + return MplexTargets.session(ctx, args.get(flag)) + + def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None): + base = self.s.resolver_for(session, window, pane, client) + if extra: + def resolve(name): + return extra[name] if name in extra else base(name) + else: + resolve = base + return MplexFormat.with_time(MplexFormat.expand(text, resolve)) + + def listing(self, command, args, rows): + template = args.get("F") or MPLEX_DEFAULT_FORMATS[command] + flt = args.get("f") + lines = [] + for session, window, pane, client, extra in rows: + if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)): + continue + lines.append(self.fmt(template, session, window, pane, client, extra)) + return "\n".join(lines) + + def substitute(self, template, answers): + out = [] + in_quote = False + pos = 0 + while pos < len(template): + char = template[pos] + if char == "\\" and pos + 1 < len(template): + out.append(template[pos:pos + 2]) + pos += 2 + continue + if char == '"': + in_quote = not in_quote + if char == "%" and pos + 1 < len(template): + nxt = template[pos + 1] + index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None) + if index is not None: + value = answers[index] if index < len(answers) else "" + if in_quote: + value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + out.append(value) + pos += 2 + continue + out.append(char) + pos += 1 + return "".join(out) + + def run_later(self, ctx, template, answers): + """Run template after a prompt finished, against the pane the prompt was opened for.""" + pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None + fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True) + fresh.depth = ctx.depth + 1 + self.s.run_checked(fresh, self.substitute(template, answers)) + + # ---- sessions and clients -------------------------------------------- + + def cmd_new_session(self, ctx, args): + s = self.s + name = args.get("s") or s.auto_session_name() + if ":" in name or "." in name: + raise MplexError("bad session name: %s" % name) + existing = next((item for item in s.sessions.values() if item.name == name), None) + if existing is not None and args.has("A"): + return self.attach(ctx, existing, args.has("D")) + if existing is not None: + raise MplexError("duplicate session: %s" % name) + client = ctx.client + cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd())) + env = dict(client.env) if client is not None and client.env else dict(os.environ) + for item in args.all("e"): + key, _eq, value = item.partition("=") + env[key] = value + size = (80, 24 + (1 if s.options.get("status") else 0)) + if args.get("x") or args.get("y"): + size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0)) + elif client is not None and not args.has("d"): + size = (client.cols, client.rows) + session = s.create_session(name, cwd, env, args.positional, args.get("n"), size) + if not args.has("d") and client is not None: + s.attach_client(client, session) + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active) + return "" + + def attach(self, ctx, session, detach_others): + client = self.need_client_for_attach(ctx) + if detach_others: + for other in list(self.s.attached_clients(session)): + if other is not client: + self.s.detach_client(other, "[detached (from session %s)]" % session.name) + self.s.attach_client(client, session) + return "" + + def need_client_for_attach(self, ctx): + if ctx.client is None: + raise MplexError("no client to attach") + return ctx.client + + def cmd_attach_session(self, ctx, args): + if args.get("t"): + session = MplexTargets.session(ctx, args.get("t")) + else: + session = self.s.latest_session() + if session is None: + raise MplexError("no sessions") + return self.attach(ctx, session, args.has("d")) + + def cmd_detach_client(self, ctx, args): + s = self.s + if args.get("s"): + session = MplexTargets.session(ctx, args.get("s")) + targets = list(s.attached_clients(session)) + else: + current = self.find_client(ctx, args.get("t")) + targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current] + for client in targets: + s.detach_client(client, "[detached (from session %s)]" % client.session.name) + return "" + + def find_client(self, ctx, spec): + if not spec: + return self.need_client(ctx) + for client in self.s.attached_clients(): + if spec in (client.tty, str(client.id), str(client.pid)): + return client + raise MplexError("can't find client: %s" % spec) + + def cmd_has_session(self, ctx, args): + MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None)) + return "" + + def cmd_kill_session(self, ctx, args): + target = self.session_target(ctx, args) + if args.has("a"): + for session in [item for item in self.s.sessions.values() if item is not target]: + self.s.kill_session(session) + else: + self.s.kill_session(target) + return "" + + def cmd_kill_server(self, ctx, args): + self.s.stop() + return "" + + def cmd_start_server(self, ctx, args): + return "" + + def cmd_list_sessions(self, ctx, args): + rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)] + return self.listing("list-sessions", args, rows) + + def cmd_list_windows(self, ctx, args): + sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] + rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()] + return self.listing("list-windows", args, rows) + + def cmd_list_panes(self, ctx, args): + s = self.s + if args.has("a") or args.has("s"): + sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] + windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()] + else: + sess, _index, window = self.window_target(ctx, args) + windows = [(sess, window)] + rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()] + return self.listing("list-panes", args, rows) + + def cmd_list_clients(self, ctx, args): + rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()] + return self.listing("list-clients", args, rows) + + def cmd_rename_session(self, ctx, args): + session = self.session_target(ctx, args) + name = args.positional[0] + if not name or ":" in name or "." in name: + raise MplexError("bad session name: %s" % name) + if any(item.name == name and item is not session for item in self.s.sessions.values()): + raise MplexError("duplicate session: %s" % name) + session.name = name + self.s.mark_all_dirty() + return "" + + def cmd_switch_client(self, ctx, args): + client = self.need_client(ctx) + s = self.s + if args.has("T"): + client.table = args.get("T") + client.repeating = False + return "" + ordered = sorted(s.sessions.values(), key=lambda x: x.id) + here = ordered.index(client.session) + window = None + if args.has("n"): + target = ordered[(here + 1) % len(ordered)] + elif args.has("p"): + target = ordered[(here - 1) % len(ordered)] + elif args.has("l"): + target = getattr(client, "last_session", None) + if target is None or target.id not in s.sessions: + raise MplexError("no last session") + elif args.get("t"): + spec = args.get("t") + if spec.startswith("%"): + target, window = MplexTargets.pane(ctx, spec)[:2] + elif ":" in spec or spec.startswith("@"): + target, _index, window = MplexTargets.window(ctx, spec) + else: + target = MplexTargets.session(ctx, spec) + else: + raise MplexError("switch-client needs -n, -p, -l, -t or -T") + previous = client.session + s.attach_client(client, target) + if target is not previous: + client.last_session = previous + if window is not None: + target.select(target.index_of(window)) + return "" + + def cmd_suspend_client(self, ctx, args): + self.need_client(ctx).send(b"Z") + return "" + + def cmd_refresh_client(self, ctx, args): + client = self.need_client(ctx) + client.prev = None + client.dirty = True + return "" + + def cmd_list_commands(self, ctx, args): + lines = [] + for name in sorted(MPLEX_COMMAND_SPECS): + aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] + lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else "")) + return "\n".join(lines) + + # ---- windows --------------------------------------------------------- + + def cmd_new_window(self, ctx, args): + s = self.s + spec = args.get("t") + session = ctx.session + index = None + anchor = None + if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")): + has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None + if not has_window: + spec = spec + ":" + if spec and spec.endswith(":"): + session = MplexTargets.session(ctx, spec[:-1]) + elif spec and self.is_new_index(ctx, spec): + sess_part, _sep, idx_part = spec.rpartition(":") + session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session + index = int(idx_part.lstrip("=")) + elif spec: + session, index, anchor = MplexTargets.window(ctx, spec) + if session is None: + raise MplexError("no current session") + if index is None: + index = session.free_index(s.options.get("base-index")) + current = session.current + if (args.has("a") or args.has("b")) and current is not None: + index = current + 1 if args.has("a") else current + self.make_room(session, index) + elif anchor is not None: + if args.has("a"): + index += 1 + self.make_room(session, index) + elif args.has("b"): + self.make_room(session, index) + elif args.has("k"): + s.kill_window(anchor) + else: + raise MplexError("index in use: %d" % index) + cwd = os.path.expanduser(args.get("c") or session.cwd) + env_extra = dict(item.partition("=")[::2] for item in args.all("e")) + if args.has("S"): + for idx in session.indexes(): + if session.windows[idx].name == args.get("n"): + session.select(idx) + return "" + created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d")) + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active) + return "" + + def is_new_index(self, ctx, spec): + """-t names an index that does not exist yet (the usual new-window -t :N case).""" + if ":" not in spec: + return False + sess_part, _sep, idx_part = spec.rpartition(":") + idx_part = idx_part.lstrip("=") + if not idx_part.isdigit(): + return False + try: + session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session + except MplexError: + return False + return int(idx_part) not in session.windows + + def make_room(self, session, index): + if index not in session.windows: + return + current = session.window() + moved = {} + for idx in session.indexes(): + moved[idx + 1 if idx >= index else idx] = session.windows[idx] + session.windows = moved + session.current = session.index_of(current) if current else None + + def cmd_kill_window(self, ctx, args): + session, index, window = self.window_target(ctx, args) + if args.has("a"): + for other in [session.windows[i] for i in session.indexes() if i != index]: + self.s.kill_window(other) + else: + self.s.kill_window(window) + return "" + + def cmd_select_window(self, ctx, args): + if args.has("n") or args.has("p"): + return self.step_window(ctx, args, 1 if args.has("n") else -1) + if args.has("l"): + return self.cmd_last_window(ctx, args) + session, index, window = self.window_target(ctx, args) + if args.has("T") and index == session.current: + if session.last is None: + raise MplexError("no last window") + session.select(session.last) + self.s.mark_dirty() + return "" + session.select(index) + self.s.mark_dirty() + return "" + + def step_window(self, ctx, args, step): + session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]) + if args.has("a"): + raise MplexError("no %s window" % ("next" if step > 0 else "previous")) + indexes = session.indexes() + here = indexes.index(session.current) + session.select(indexes[(here + step) % len(indexes)]) + self.s.mark_dirty() + return "" + + def cmd_next_window(self, ctx, args): + return self.step_window(ctx, args, 1) + + def cmd_previous_window(self, ctx, args): + return self.step_window(ctx, args, -1) + + def cmd_last_window(self, ctx, args): + session = MplexTargets.session(ctx, args.get("t")) + if session.last not in session.windows: + raise MplexError("no last window") + session.select(session.last) + self.s.mark_dirty() + return "" + + def cmd_rename_window(self, ctx, args): + session, index, window = self.window_target(ctx, args) + if not args.positional[0]: + raise MplexError("empty window name") + window.name = args.positional[0][:64] + window.auto_rename = False + self.s.mark_all_dirty() + return "" + + def parse_window_destination(self, ctx, spec): + if ":" in spec: + sess_part, _sep, idx_part = spec.rpartition(":") + session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session + else: + session, idx_part = ctx.session, spec + idx_part = idx_part.lstrip("=") + if not idx_part.lstrip("-").isdigit(): + raise MplexError("bad window index: %s" % idx_part) + return session, int(idx_part) + + def cmd_move_window(self, ctx, args): + s = self.s + if args.has("r"): + s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0])) + s.mark_all_dirty() + return "" + src_session, src_index, window = MplexTargets.window(ctx, args.get("s")) + if not args.get("t"): + raise MplexError("move-window needs -t") + dst_session, idx = self.parse_window_destination(ctx, args.get("t")) + if args.has("a"): + idx += 1 + self.make_room(dst_session, idx) + elif args.has("b"): + self.make_room(dst_session, idx) + elif idx in dst_session.windows and dst_session.windows[idx] is not window: + if args.has("k"): + s.kill_window(dst_session.windows[idx]) + else: + raise MplexError("index in use: %d" % idx) + if dst_session is src_session and idx == src_index: + return "" + was_current = src_session.current == src_index + src_session.windows.pop(src_index) + src_session.history = [item for item in src_session.history if item is not window] + window.session = dst_session + window.options.parent = dst_session.options + dst_session.windows[idx] = window + if was_current and src_session.windows and src_session is not dst_session: + src_session.current = src_session.indexes()[0] + elif was_current and src_session is dst_session: + src_session.current = idx + if not args.has("d"): + dst_session.select(idx) + if not src_session.windows: + s.kill_session(src_session) + else: + s.resize_session(src_session) + s.resize_session(dst_session) + return "" + + def cmd_swap_window(self, ctx, args): + src_session, src_index, src = MplexTargets.window(ctx, args.get("s")) + dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t")) + was = (src_session.window(), dst_session.window()) + src_session.windows[src_index], dst_session.windows[dst_index] = dst, src + src.session, dst.session = dst_session, src_session + src.options.parent, dst.options.parent = dst_session.options, src_session.options + for session, current in ((src_session, was[0]), (dst_session, was[1])): + if current is not None: + session.current = session.index_of(current) + self.s.resize_session(src_session) + self.s.resize_session(dst_session) + return "" + + def cmd_find_window(self, ctx, args): + s = self.s + text = args.positional[0] + fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "") + fields = fields or "CNT" + if args.has("r"): + flags = re.IGNORECASE if args.has("i") else 0 + try: + matcher = re.compile(text, flags).search + except re.error as exc: + raise MplexError("bad regular expression: %s" % exc) + else: + import fnmatch + pattern = "*%s*" % text + low = pattern.lower() if args.has("i") else pattern + + def matcher(value): + return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low) + + found = [] + for session in sorted(s.sessions.values(), key=lambda x: x.id): + for index in session.indexes(): + window = session.windows[index] + hit = False + if "N" in fields and matcher(window.name): + hit = True + for pane in window.panes(): + if "T" in fields and matcher(pane.title): + hit = True + if "C" in fields and matcher(pane.screen.text()): + hit = True + if hit: + found.append((session, index, window)) + if not found: + raise MplexError("no windows matching: %s" % text) + if len(found) == 1: + session, index, _window = found[0] + client = ctx.client + if client is not None and client.attached and client.session is not session: + s.attach_client(client, session) + session.select(index) + s.mark_dirty() + return "" + items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found] + return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args) + + # ---- panes ----------------------------------------------------------- + + def size_arg(self, text, total): + text = text.strip() + if text.endswith("%"): + return max(1, total * int(text[:-1]) // 100) + return int(text) + + def cmd_split_window(self, ctx, args): + s = self.s + session, window, target = self.pane_target(ctx, args) + kind = "lr" if args.has("h") else "tb" + leaf = window.leaf_of(target) + size = None + if args.get("l"): + try: + size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h) + except ValueError: + raise MplexError("bad size: %s" % args.get("l")) + cwd = os.path.expanduser(args.get("c") or session.cwd) + env_extra = dict(item.partition("=")[::2] for item in args.all("e")) + pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d")) + if args.has("Z") and not args.has("d"): + window.zoomed = pane + window.relayout() + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane) + return "" + + def cmd_kill_pane(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + if args.has("a"): + for other in [item for item in window.panes() if item is not pane]: + self.s.kill_pane(other) + else: + self.s.kill_pane(pane) + return "" + + def cmd_select_pane(self, ctx, args): + s = self.s + if args.has("e") or args.has("d"): + raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex") + if args.has("M"): + s.marked = None + s.mark_all_dirty() + return "" + session, window, pane = self.pane_target(ctx, args) + if args.has("m"): + s.marked = None if s.marked is pane else pane + s.mark_all_dirty() + return "" + if args.get("T") is not None: + pane.screen.title = args.get("T")[:256] + s.mark_all_dirty() + return "" + new = pane + for flag in "UDLR": + if args.has(flag): + found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height) + if found is None: + raise MplexError("no pane in that direction") + new = found + if args.has("l"): + if window.last_pane not in window.panes(): + raise MplexError("no last pane") + new = window.last_pane + keep_zoom = args.has("Z") and window.zoomed is not None + zoomed = window.zoomed + s.select_pane(window, new) + if keep_zoom and zoomed is not None: + window.zoomed = new + window.relayout() + if session.window() is not window: + session.select(session.index_of(window)) + return "" + + def cmd_last_pane(self, ctx, args): + _session, _index, window = MplexTargets.window(ctx, args.get("t")) + if window.last_pane not in window.panes(): + raise MplexError("no last pane") + self.s.select_pane(window, window.last_pane) + return "" + + def cmd_resize_pane(self, ctx, args): + if args.has("M") or args.has("T"): + raise MplexError("resize-pane -M/-T is not supported by mplex") + session, window, pane = self.pane_target(ctx, args) + if args.has("Z"): + if window.zoomed is not None: + window.zoomed = None + elif len(window.panes()) > 1: + window.zoomed = pane + window.relayout() + self.s.mark_dirty(window) + return "" + leaf = window.leaf_of(pane) + amount = 1 + if args.positional: + try: + amount = int(args.positional[0]) + except ValueError: + raise MplexError("bad adjustment: %s" % args.positional[0]) + try: + if args.get("x"): + MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width)) + if args.get("y"): + MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height)) + if args.has("R"): + MplexLayout.resize_by(leaf, "lr", amount, True) + elif args.has("L"): + MplexLayout.resize_by(leaf, "lr", -amount, False) + elif args.has("D"): + MplexLayout.resize_by(leaf, "tb", amount, True) + elif args.has("U"): + MplexLayout.resize_by(leaf, "tb", -amount, False) + except MplexError: + pass + window.layout_index = -1 + window.relayout() + self.s.mark_dirty(window) + return "" + + def cmd_swap_pane(self, ctx, args): + s = self.s + if args.has("U") or args.has("D"): + _s, window, dst = self.pane_target(ctx, args) + panes = window.panes() + here = panes.index(dst) + src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)] + else: + _s, _w, dst = self.pane_target(ctx, args) + if args.get("s"): + _s2, _w2, src = MplexTargets.pane(ctx, args.get("s")) + elif s.marked is not None: + src = s.marked + else: + raise MplexError("no marked pane") + if src is dst: + return "" + wa, wb = src.window, dst.window + leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst) + leaf_a.pane, leaf_b.pane = dst, src + src.window, dst.window = wb, wa + if wa is not wb: + if wa.active is src: + wa.active = dst + if wb.active is dst: + wb.active = src + for window in (wa,) if wa is wb else (wa, wb): + window.relayout() + s.marked = None + s.mark_all_dirty() + return "" + + def cmd_rotate_window(self, ctx, args): + session, index, window = self.window_target(ctx, args) + panes = window.panes() + if len(panes) < 2: + return "" + order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1] + position = panes.index(window.active) + for leaf, pane in zip(window.root.leaves(), order): + leaf.pane = pane + window.active = order[position] + window.relayout() + s = self.s + s.mark_dirty(window) + return "" + + def cmd_break_pane(self, ctx, args): + s = self.s + session, window, pane = MplexTargets.pane(ctx, args.get("s")) + if len(window.panes()) < 2: + raise MplexError("can't break with only one pane") + dst_session = session + index = dst_session.free_index(s.options.get("base-index")) + if args.get("t"): + dst_session, index = self.parse_window_destination(ctx, args.get("t")) + if index in dst_session.windows: + raise MplexError("index in use: %d" % index) + leaf = window.leaf_of(pane) + was_active = window.active is pane + window.root = MplexLayout.remove(window.root, leaf) + if window.zoomed is pane: + window.zoomed = None + rest = window.panes() + if was_active or window.active is pane: + window.active = window.last_pane if window.last_pane in rest else rest[0] + if window.last_pane is pane: + window.last_pane = None + window.relayout() + cols, rows = getattr(dst_session, "size", (80, 24)) + fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options) + fresh.auto_rename = not args.get("n") + fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height) + fresh.active = pane + pane.window = fresh + s.register_window(dst_session, fresh, index, not args.has("d")) + fresh.relayout() + s.mark_all_dirty() + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane) + return "" + + def apply_layout(self, window, name, index): + panes = window.panes() + opts = window.options + window.zoomed = None + window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height")) + window.layout_index = index + window.relayout() + self.s.mark_dirty(window) + + def cmd_select_layout(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + names = MPLEX_LAYOUT_NAMES + if args.has("E"): + leaf = window.leaf_of(pane) + parent = leaf.parent + if parent is not None: + for child in parent.children: + MplexLayout.set_size(child, parent.kind, 1) + window.relayout() + self.s.mark_dirty(window) + return "" + if args.positional: + wanted = args.positional[0] + matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)] + if len(matches) != 1: + raise MplexError("unknown or ambiguous layout: %s" % wanted) + self.apply_layout(window, matches[0], names.index(matches[0])) + return "" + if args.has("p"): + index = (window.layout_index - 1) % len(names) + elif args.has("o"): + index = window.layout_index if window.layout_index >= 0 else 0 + else: + index = (window.layout_index + 1) % len(names) + self.apply_layout(window, names[index], index) + return "" + + def cmd_next_layout(self, ctx, args): + session, index, window = self.window_target(ctx, args) + self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)) + return "" + + def cmd_previous_layout(self, ctx, args): + session, index, window = self.window_target(ctx, args) + step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES) + self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step) + return "" + + def cmd_clear_history(self, ctx, args): + _s, _w, pane = self.pane_target(ctx, args) + pane.clear_history() + return "" + + def cmd_capture_pane(self, ctx, args): + _s, _w, pane = self.pane_target(ctx, args) + screen = pane.screen + if args.has("a") and not screen.alt_active: + if args.has("q"): + return "" + raise MplexError("no alternate screen") + history = list(screen.history or []) + rows = history + [list(row) for row in screen.grid] + base = len(history) + + def line_of(row): + text = "".join(cell[0] if cell else " " for cell in row) + return text if args.has("N") else text.rstrip() + + def bound(value, default): + if value is None: + return default + if value == "-": + return None + try: + return base + int(value) + except ValueError: + raise MplexError("bad line number: %s" % value) + + start = bound(args.get("S"), base) + end = bound(args.get("E"), base + pane.h - 1) + start = 0 if start is None else max(0, start) + end = len(rows) - 1 if end is None else min(len(rows) - 1, end) + text = "\n".join(line_of(rows[i]) for i in range(start, end + 1)) + if args.has("p"): + return text + self.s.add_buffer(text + "\n", args.get("b")) + return "" + + # ---- keys, prompts, input -------------------------------------------- + + def cmd_send_prefix(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + name = session.options.get("prefix2" if args.has("2") else "prefix") + if name == "None": + raise MplexError("no prefix key set") + self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor)) + return "" + + def cmd_send_keys(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + words = list(args.positional) + count = args.int("N", None) + if args.has("M"): + raise MplexError("send-keys -M (mouse) is not supported by mplex") + if args.has("X"): + mode = pane.top_mode() + if not isinstance(mode, MplexCopyMode): + raise MplexError("pane is not in a mode") + if not words: + raise MplexError("send-keys -X needs a command") + if len(words) > 1 and words[1] == "--": + del words[1] + if args.has("F"): + words = [self.fmt(item, session, window, pane, ctx.client) for item in words] + if count: + pane.prefix_count = count + mode.command(words[0], words[1:], ctx.client) + return "" + if args.has("R"): + pane.screen.reset() + if not words: + if count: + pane.prefix_count = count + return "" + if args.has("F"): + words = [self.fmt(item, session, window, pane, ctx.client) for item in words] + data = b"" + if args.has("l"): + data = "".join(words).encode("utf-8") + elif args.has("H"): + try: + data = bytes(int(item, 16) for item in words) + except ValueError: + raise MplexError("bad hex byte in send-keys -H") + else: + for word in words: + try: + data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor) + except MplexError: + data += word.encode("utf-8") + mode = pane.top_mode() + for _i in range(max(1, min(count or 1, 1000))): + if mode is not None and not args.has("l") and not args.has("H"): + tokens, _rest = MplexKeys.split(data, True) + for name, raw in tokens: + mode.key(name, raw, ctx.client) + elif not pane.dead: + pane.write(data) + self.s.mark_dirty(window) + return "" + + def cmd_bind_key(self, ctx, args): + table = "root" if args.has("n") else (args.get("T") or "prefix") + key = MplexKeys.canonical(args.positional[0]) + words = args.positional[1:] + if not words: + raise MplexError("bind-key needs a command") + command = " ".join(MplexCommandParser.quote(word) for word in words) + for parsed in MplexCommandParser.parse(command): + self.resolve(parsed[0]) + self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "") + return "" + + def cmd_unbind_key(self, ctx, args): + table = "root" if args.has("n") else (args.get("T") or "prefix") + config = self.s.config + if args.has("a"): + config.tables[table] = {} + return "" + if not args.positional: + raise MplexError("unbind-key needs a key (or -a)") + key = MplexKeys.canonical(args.positional[0]) + if not config.unbind(table, key) and not args.has("q"): + raise MplexError("unknown key: %s" % args.positional[0]) + return "" + + def cmd_list_keys(self, ctx, args): + config = self.s.config + opts = ctx.session.options if ctx.session else self.s.options + only = MplexKeys.canonical(args.positional[0]) if args.positional else None + tables = [args.get("T")] if args.get("T") else list(config.tables) + lines = [] + if args.has("N"): + prefix = opts.get("prefix") + rows = [] + for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]): + for key, binding in config.tables.get(table, {}).items(): + if only is not None and key != only: + continue + note = binding.note or (binding.command if args.has("a") else "") + if not note: + continue + label = key if table == "root" else "%s %s" % (prefix, key) + if args.get("P") is not None: + label = args.get("P") + key + rows.append((label, note)) + if only is not None and not rows: + raise MplexError("unknown key: %s" % args.positional[0]) + if args.has("1"): + rows = rows[:1] + width = max([len(label) for label, _note in rows] or [0]) + return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows) + for table in tables: + for key, binding in config.tables.get(table, {}).items(): + if only is not None and key != only: + continue + lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command)) + if args.has("1") and lines: + lines = lines[:1] + return "\n".join(lines) + + def cmd_command_prompt(self, ctx, args): + s = self.s + client = self.need_client(ctx) + template = args.positional[0] if args.positional else None + prompts = args.get("p").split(",") if args.get("p") else [] + inputs = args.get("I").split(",") if args.get("I") else [] + prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts] + inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs] + if template is not None and args.has("F"): + template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client) + kind = args.get("T") or "command" + history = s.prompt_history.setdefault(kind, []) + answers = [] + count = max(1, len(prompts)) + pane = ctx.pane + copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None + + def ask(position): + label = (prompts[position] + " ") if prompts else ":" + initial = inputs[position] if position < len(inputs) else "" + + def done(value): + answers.append(value) + if position + 1 < count: + ask(position + 1) + return + if template is None: + self.run_later(ctx, "%%", answers) + else: + self.run_later(ctx, template, answers) + + kwargs = {} + if args.has("i") and template is not None: + kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text]) + kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text]) + if copy_mode is not None: + kwargs["on_cancel"] = copy_mode.search_cancel + client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs) + client.dirty = True + + ask(0) + return "" + + def cmd_confirm_before(self, ctx, args): + client = self.need_client(ctx) + command = args.positional[0] + text = args.get("p") or "Confirm '%s'? (y/n)" % command + text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client) + pane = ctx.pane + + def run(): + fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True) + fresh.depth = ctx.depth + 1 + self.s.run_checked(fresh, command) + + self.s.ask_confirm(client, text, run) + return "" + + def cmd_display_message(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"] + text = self.fmt(template, session, window, pane, ctx.client) + text = MplexFormat.strip_styles(text) + if args.has("p"): + return text + client = ctx.client + if client is not None and client.attached: + delay = args.int("d", None) + self.s.message(client, text, delay) + return "" + + def cmd_display_panes(self, ctx, args): + client = self.need_client(ctx) + window = client.session.window() + duration = args.int("d", None) or window.options.get("display-panes-time") + client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0) + client.dirty = True + return "" + + def cmd_clock_mode(self, ctx, args): + _s, window, pane = self.pane_target(ctx, args) + if pane.find_mode(MplexClockMode) is None: + pane.push_mode(MplexClockMode(self.s, pane)) + self.s.mark_dirty(window) + return "" + + def cmd_copy_mode(self, ctx, args): + if args.has("M"): + raise MplexError("copy-mode -M (mouse) is not supported by mplex") + _s, window, pane = self.pane_target(ctx, args) + existing = pane.find_mode(MplexCopyMode) + if args.has("q"): + if existing is not None: + existing.close() + return "" + if existing is None: + existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H")) + pane.push_mode(existing) + if args.has("u"): + existing.command("page-up", [], ctx.client) + self.s.mark_dirty(window) + return "" + + def cmd_show_messages(self, ctx, args): + return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages) + + # ---- buffers --------------------------------------------------------- + + def buffer_rows(self): + rows = [] + for item in self.s.buffers: + sample = item["data"][:50].replace("\n", " ") + rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample})) + return rows + + def cmd_list_buffers(self, ctx, args): + return self.listing("list-buffers", args, self.buffer_rows()) + + def cmd_set_buffer(self, ctx, args): + name = args.get("b") + if args.get("n"): + item = self.s.find_buffer(name) + item["name"] = args.get("n") + return "" + if not args.positional: + raise MplexError("set-buffer needs data") + data = args.positional[0] + if args.has("a"): + item = self.s.find_buffer(name) + item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX] + else: + self.s.add_buffer(data, name) + return "" + + def cmd_show_buffer(self, ctx, args): + return self.s.find_buffer(args.get("b"))["data"] + + def cmd_delete_buffer(self, ctx, args): + item = self.s.find_buffer(args.get("b")) + self.s.buffers.remove(item) + self.s.refresh_choosers() + return "" + + def cmd_save_buffer(self, ctx, args): + item = self.s.find_buffer(args.get("b")) + path = os.path.expanduser(args.positional[0]) + try: + with open(path, "ab" if args.has("a") else "wb") as handle: + handle.write(item["data"].encode("utf-8")) + except OSError as exc: + raise MplexError("cannot write %s: %s" % (path, short_error(exc))) + return "" + + def cmd_load_buffer(self, ctx, args): + path = os.path.expanduser(args.positional[0]) + try: + with open(path, "rb") as handle: + data = handle.read(MPLEX_PASTE_MAX) + except OSError as exc: + raise MplexError("cannot read %s: %s" % (path, short_error(exc))) + self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b")) + return "" + + def cmd_paste_buffer(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + item = self.s.find_buffer(args.get("b")) + separator = args.get("s") if args.get("s") is not None else "\r" + text = item["data"] if args.has("r") else item["data"].replace("\n", separator) + data = text.encode("utf-8") + if args.has("p") and pane.screen.bracketed_paste: + data = b"\x1b[200~" + data + b"\x1b[201~" + if not pane.dead: + pane.write(data) + if args.has("d"): + self.s.buffers.remove(item) + return "" + + # ---- choosers -------------------------------------------------------- + + def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None): + s = self.s + pane = ctx.pane + if pane is None: + raise MplexError("no pane") + window = pane.window + zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1 + if zoom_on_open: + window.zoomed = pane + window.relayout() + + def restore(): + if zoom_on_open and window.zoomed is pane: + window.zoomed = None + window.relayout() + + def choose(node, client): + kind_tag, ident = node.payload + if kind_tag == "window": + target = s.windows.get(ident) + if target is None: + return + if client is not None and client.session is not target.session: + s.attach_client(client, target.session) + target.session.select(target.session.index_of(target)) + elif kind_tag == "pane": + target = s.panes.get(ident) + if target is None or target.window is None: + return + if client is not None and client.session is not target.window.session: + s.attach_client(client, target.window.session) + target.window.session.select(target.window.session.index_of(target.window)) + s.select_pane(target.window, target) + elif kind_tag == "session": + target = s.sessions.get(ident) + if target is not None and client is not None: + s.attach_client(client, target) + s.mark_all_dirty() + + mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore) + pane.push_mode(mode) + s.mark_dirty(window) + return "" + + def cmd_choose_tree(self, ctx, args): + s = self.s + sessions_only = args.has("s") + windows_collapsed = args.has("w") + + def builder(): + items = [] + for session in sorted(s.sessions.values(), key=lambda x: x.id): + kids = [] + for index in session.indexes(): + window = session.windows[index] + panes = [] + for number, pane in enumerate(window.panes()): + panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id))) + kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height), + ("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1)) + attached = " (attached)" if s.attached_clients(session) else "" + items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only)) + return items + + def preview(node, width, height): + kind_tag, ident = node.payload + pane = None + if kind_tag == "pane": + pane = s.panes.get(ident) + elif kind_tag == "window": + window = s.windows.get(ident) + pane = window.active if window else None + else: + session = s.sessions.get(ident) + pane = session.window().active if session else None + if pane is None: + return [] + blank = MplexCanvas.BLANK + return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]] + + def delete(node, client): + kind_tag, ident = node.payload + if kind_tag == "session" and ident in s.sessions: + s.kill_session(s.sessions[ident]) + elif kind_tag == "window" and ident in s.windows: + s.kill_window(s.windows[ident]) + elif kind_tag == "pane" and ident in s.panes: + s.kill_pane(s.panes[ident]) + + selected = ("window", ctx.window.id) if ctx.window is not None else None + return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected) + + def cmd_choose_buffer(self, ctx, args): + s = self.s + pane = ctx.pane + + def builder(): + return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers] + + def choose(node, client): + if pane is not None and pane.id in s.panes and not pane.dead: + item = s.find_buffer(node.payload[1]) + pane.write(item["data"].replace("\n", "\r").encode("utf-8")) + + def delete(node, client): + s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]] + + def preview(node, width, height): + data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "") + return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]] + + if not s.buffers: + raise MplexError("no buffers") + return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview) + + def cmd_choose_client(self, ctx, args): + s = self.s + + def builder(): + return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()] + + def choose(node, client): + for target in s.attached_clients(): + if target.id == node.payload[1]: + s.detach_client(target, "[detached (from session %s)]" % target.session.name) + + return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose) + + # ---- options, shell, files ------------------------------------------- + + def option_name(self, name): + if name in MplexOptions.DEFS: + return name + matches = [item for item in MplexOptions.DEFS if item.startswith(name)] + if len(matches) == 1: + return matches[0] + raise MplexError("unknown or unsupported option: %s" % name) + + def option_scope(self, ctx, args, window_scope): + if args.has("g") or args.has("s"): + return self.s.options + if window_scope or args.has("w"): + return MplexTargets.window(ctx, args.get("t"))[2].options + return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options + + def set_option(self, ctx, args, window_scope): + try: + name = self.option_name(args.positional[0]) + except MplexError: + if args.has("q"): + return "" + raise + scope = self.option_scope(ctx, args, window_scope) + if args.has("u"): + scope.unset(name) + else: + if len(args.positional) < 2: + scope.toggle(name) + else: + value = args.positional[1] + if args.has("F"): + value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client) + if args.has("o") and scope.is_set(name): + raise MplexError("already set: %s" % name) + scope.set(name, value, args.has("a")) + self.s.resize_all() + self.s.mark_all_dirty() + return "" + + def cmd_set_option(self, ctx, args): + return self.set_option(ctx, args, False) + + def cmd_set_window_option(self, ctx, args): + return self.set_option(ctx, args, True) + + def show_options(self, ctx, args, window_scope): + scope = self.option_scope(ctx, args, window_scope) + names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS) + lines = [] + for name in names: + if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name): + value = scope.get(name) + text = MplexOptions.show(value) + if args.has("v"): + lines.append(text) + else: + lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text)) + return "\n".join(lines) + + def cmd_show_options(self, ctx, args): + return self.show_options(ctx, args, False) + + def cmd_show_window_options(self, ctx, args): + return self.show_options(ctx, args, True) + + def shell_in(self, ctx, command, pane): + cwd = pane.current_path() if pane is not None else None + env = dict(ctx.session.env) if ctx.session is not None else None + try: + done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env) + except (OSError, subprocess.SubprocessError) as exc: + raise MplexError("shell command failed: " + short_error(exc)) + return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace") + + def cmd_if_shell(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + condition = args.positional[0] + if args.has("F"): + ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client)) + else: + ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0 + chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "") + if not chosen: + return "" + return self.s.run_line(ctx, chosen, ctx.depth + 1) + + def cmd_run_shell(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + if args.get("d"): + raise MplexError("run-shell -d (delay) is not supported by mplex") + if not args.positional: + return "" + command = self.fmt(args.positional[0], session, window, pane, ctx.client) + if args.has("C"): + return self.s.run_line(ctx, command, ctx.depth + 1) + if args.has("b"): + try: + subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=pane.current_path() or None, start_new_session=True) + except OSError as exc: + raise MplexError("shell command failed: " + short_error(exc)) + return "" + return self.shell_in(ctx, command, pane)[1].rstrip("\n") + + def cmd_source_file(self, ctx, args): + s = self.s + out = [] + for raw in args.positional: + path = os.path.expanduser(raw) + if not os.path.exists(path): + if args.has("q"): + continue + raise MplexError("no such file: %s" % raw) + if path.endswith(".toml"): + before = s.config.skipped + s.config.apply_file(path) + for problem in s.config.errors[before:]: + s.log_message("mplex.toml: " + problem) + skipped = s.config.skipped - before + if skipped: + out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped)) + s.resize_all() + s.mark_all_dirty() + continue + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read(MPLEX_CONFIG_MAX).splitlines() + except OSError as exc: + raise MplexError("cannot read %s: %s" % (path, short_error(exc))) + for number, line in enumerate(lines, 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + try: + text = s.run_line(ctx, line, ctx.depth + 1) + except MplexError as exc: + raise MplexError("%s:%d: %s" % (raw, number, exc)) + if text: + out.append(text) + return "\n".join(out) + + +class MplexClient: + """The attached terminal: forwards keys and resizes to the server, paints what it sends.""" + + def __init__(self, home, commands): + self.home = home + self.commands = commands + self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) + self.stdin_fd = None + self.saved = None + self.attached = False + self.wake_r = None + self.wake_w = None + + @staticmethod + def frame(kind, payload=b""): + return kind + struct.pack(">I", len(payload)) + payload + + def size(self): + try: + packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8) + rows, cols = struct.unpack("HH", packed[:4]) + if rows and cols: + return rows, cols + except (OSError, ValueError, AttributeError): + pass + return 24, 80 + + def hello(self): + rows, cols = self.size() + pane = os.environ.get(PLEXER_PANE_ENV, "") + info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(), + "tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands, + "env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""} + return self.frame(b"H", json.dumps(info).encode("utf-8")) + + def enter(self): + self.stdin_fd = sys.stdin.fileno() + try: + self.saved = termios.tcgetattr(self.stdin_fd) + tty.setraw(self.stdin_fd) + except (termios.error, OSError): + self.saved = None + os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H") + self.attached = True + + def leave(self): + if not self.attached: + return + self.attached = False + try: + os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l") + except OSError: + pass + if self.saved is not None: + try: + termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved) + except (termios.error, OSError): + pass + + def write_out(self, data): + fd = sys.stdout.fileno() + view = memoryview(data) + while view: + try: + written = os.write(fd, view) + except BlockingIOError: + select.select([], [fd], [], 1.0) + continue + view = view[written:] + + def on_winch(self, _signum, _frame): + try: + os.write(self.wake_w, b"w") + except OSError: + pass + + def run(self): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(self.sock_path) + except OSError: + sys.stderr.write("no server running on %s\n" % self.sock_path) + return 1 + sock.sendall(MPLEX_MAGIC + self.hello()) + self.wake_r, self.wake_w = os.pipe() + os.set_blocking(self.wake_w, False) + previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None + buf = bytearray() + code = 0 + message = "" + try: + while True: + watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else []) + try: + ready, _w, _x = select.select(watch, [], []) + except InterruptedError: + continue + if self.wake_r in ready: + os.read(self.wake_r, 4096) + rows, cols = self.size() + sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) + if self.attached and self.stdin_fd in ready: + data = os.read(self.stdin_fd, 4096) + if not data: + message = "[detached]" + break + sock.sendall(self.frame(b"I", data)) + if sock in ready: + data = sock.recv(262144) + if not data: + code = 1 + message = "[lost server]" + break + buf += data + done = self.handle_frames(sock, buf) + if done is not None: + code, message = done + break + finally: + self.leave() + if previous is not None: + signal.signal(signal.SIGWINCH, previous) + sock.close() + os.close(self.wake_r) + os.close(self.wake_w) + if message: + (sys.stderr if code else sys.stdout).write(message + "\n") + return code + + def handle_frames(self, sock, buf): + while len(buf) >= 5: + length = struct.unpack(">I", bytes(buf[1:5]))[0] + if len(buf) < 5 + length: + return None + kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length]) + del buf[:5 + length] + if kind == b"A": + self.enter() + elif kind == b"O": + self.write_out(payload) + elif kind == b"M": + sys.stdout.write(payload.decode("utf-8", "replace") + "\n") + sys.stdout.flush() + elif kind == b"E": + return 1, payload.decode("utf-8", "replace") + elif kind in (b"D", b"X"): + return 0, payload.decode("utf-8", "replace") + elif kind == b"Z": + self.suspend(sock) + return None + + def suspend(self, sock): + self.leave() + os.kill(os.getpid(), signal.SIGTSTP) + self.enter() + rows, cols = self.size() + sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) + + +class MplexCli: + """tai.py --mplex : any mplex command; new-session and attach-session attach this terminal.""" + + @staticmethod + def attaches(name, rest): + if name == "attach-session": + return True + if name != "new-session": + return False + try: + return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d") + except MplexError: + return True + + @staticmethod + def split_commands(words): + """argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument.""" + commands = [[]] + for word in words: + if word == ";": + commands.append([]) + else: + commands[-1].append(";" if word == "\\;" else word) + return [item for item in commands if item] + + @classmethod + def main(cls, home, words): + commands = cls.split_commands(words) or [["new-session"]] + try: + name = MplexCommands.resolve(commands[0][0]) + except MplexError as exc: + sys.stderr.write("%s\n" % exc) + return 1 + commands[0][0] = name + words = commands[0] + if cls.attaches(name, words[1:]): + if os.environ.get(PLEXER_ACTIVE_ENV): + sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV) + return 1 + if not (sys.stdin.isatty() and sys.stdout.isatty()): + sys.stderr.write("open terminal failed: not a terminal\n") + return 1 + if name in ("new-session", "start-server"): + if not MplexServer.ensure_running(home): + sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME)) + return 1 + return MplexClient(home, commands).run() + + +class NativePlexerBackend(PlexerBackend): + """What the shell hook execs into: a new mplex session attached to this terminal.""" + + def __init__(self, home, profile, shell_cmd=None): + self.home = home + self.profile = profile + self.shell_cmd = shell_cmd + + def run(self): + if pty is None or termios is None or select is None or fcntl is None: + return "error: pty/termios/select not available on this platform" + return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else [])) def plexer_should_wrap(env): @@ -11779,7 +18137,11 @@ class TuiVtScreen: ATTR_UNDERLINE = 4 ATTR_REVERSE = 8 ATTR_STRIKE = 16 + ATTR_ITALIC = 32 + ATTR_BLINK = 64 + ATTR_HIDDEN = 128 TAB_WIDTH = 8 + GRAPHICS = {"`": "\u25c6", "a": "\u2592", "f": "\u00b0", "g": "\u00b1", "j": "\u2518", "k": "\u2510", "l": "\u250c", "m": "\u2514", "n": "\u253c", "o": "\u23ba", "p": "\u23bb", "q": "\u2500", "r": "\u23bc", "s": "\u23bd", "t": "\u251c", "u": "\u2524", "v": "\u2534", "w": "\u252c", "x": "\u2502", "y": "\u2264", "z": "\u2265", "{": "\u03c0", "|": "\u2260", "}": "\u00a3", "~": "\u00b7"} TAIL_GUARD = 65536 def __init__(self, rows, cols): @@ -11799,6 +18161,23 @@ class TuiVtScreen: self._alt = None self._buf = "" self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self.history = None + self.history_pushed = 0 + self.app_cursor = False + self.bracketed_paste = False + self.title = "" + self.replies = bytearray() + self._graphics = False + self._last_char = "" + + @property + def alt_active(self): + return self._alt is not None + + def take_replies(self): + data = bytes(self.replies) + self.replies.clear() + return data def blank(self): return (" ", None, self.bg, 0) @@ -11856,6 +18235,9 @@ class TuiVtScreen: self._wrap = False self.cx = 0 self.line_feed() + if self._graphics: + char = self.GRAPHICS.get(char, char) + self._last_char = char self.grid[self.cy][self.cx] = (char, self.fg, self.bg, self.attr) if self.cx == self.cols - 1: self._wrap = True @@ -11868,11 +18250,18 @@ class TuiVtScreen: nxt = buf[pos + 1] if nxt == "[": return self._parse_csi(buf, pos + 2, end) - if nxt == "]" or nxt in "PX^_": + if nxt == "]": + stop = self._parse_until_st(buf, pos + 2, end) + if stop is not None: + self._osc(buf[pos + 2:stop]) + return stop + if nxt in "PX^_": return self._parse_until_st(buf, pos + 2, end) if nxt in "()#%": if pos + 2 >= end: return None + if nxt == "(": + self._graphics = buf[pos + 2] == "0" return pos + 3 if nxt == "7": self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr) @@ -11895,6 +18284,12 @@ class TuiVtScreen: self.line_feed() return pos + 2 + def _osc(self, body): + body = body.rstrip("\x07\x1b\\") + code, _sep, text = body.partition(";") + if code in ("0", "2"): + self.title = text[:256] + def _parse_csi(self, buf, pos, end): cur = pos while cur < end and (buf[cur].isdigit() or buf[cur] in ";:?>", ">0"): + self.replies += b"\x1b[>0;0;0c" if body[:1] == ">" else b"\x1b[?1;2c" + return + if final == "n" and not private: + code = self._params(body, 1, 0)[0] + if code == 5: + self.replies += b"\x1b[0n" + elif code == 6: + self.replies += ("\x1b[%d;%dR" % (self.cy + 1, self.cx + 1)).encode("ascii") + return if final == "h" or final == "l": self._mode(body, final == "h") return @@ -12028,6 +18433,10 @@ class TuiVtScreen: self.region_bottom = bottom - 1 self.cx = 0 self.cy = self.region_top + elif final == "b": + if self._last_char: + for _i in range(min(self._params(body, 1)[0], self.cols * self.rows)): + self._ground(self._last_char) elif final == "s": self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr) elif final == "u": @@ -12043,6 +18452,10 @@ class TuiVtScreen: for value in self._params(body, None, 0): if value == 25 and private: self.cursor_visible = state + elif value == 1 and private: + self.app_cursor = state + elif value == 2004 and private: + self.bracketed_paste = state elif value in (47, 1047, 1049) and private: self._alt_screen(state) @@ -12077,18 +18490,30 @@ class TuiVtScreen: self.attr |= self.ATTR_BOLD elif code == 2: self.attr |= self.ATTR_DIM + elif code == 3: + self.attr |= self.ATTR_ITALIC elif code == 4: self.attr |= self.ATTR_UNDERLINE + elif code == 5: + self.attr |= self.ATTR_BLINK elif code == 7: self.attr |= self.ATTR_REVERSE + elif code == 8: + self.attr |= self.ATTR_HIDDEN elif code == 9: self.attr |= self.ATTR_STRIKE elif code == 22: self.attr &= ~(self.ATTR_BOLD | self.ATTR_DIM) + elif code == 23: + self.attr &= ~self.ATTR_ITALIC elif code == 24: self.attr &= ~self.ATTR_UNDERLINE + elif code == 25: + self.attr &= ~self.ATTR_BLINK elif code == 27: self.attr &= ~self.ATTR_REVERSE + elif code == 28: + self.attr &= ~self.ATTR_HIDDEN elif code == 29: self.attr &= ~self.ATTR_STRIKE elif 30 <= code <= 37 or 90 <= code <= 97: @@ -12116,6 +18541,9 @@ class TuiVtScreen: self.cy = min(self.rows - 1, self.cy + 1) def scroll_up(self, top, bottom): + if self.history is not None and top == 0 and bottom == self.rows - 1 and self._alt is None: + self.history.append(self.grid[0]) + self.history_pushed += 1 for row in range(top, bottom): self.grid[row] = self.grid[row + 1] self.grid[bottom] = [self.blank()] * self.cols @@ -12201,6 +18629,9 @@ class TuiVtScreen: self._wrap = False self._saved = None self._alt = None + self.app_cursor = False + self.bracketed_paste = False + self._graphics = False def resize(self, rows, cols): rows = max(1, int(rows)) @@ -15643,6 +22074,7 @@ def build_parser(): parser.add_argument("--install-telegram", action="store_true") parser.add_argument("--uninstall-telegram", action="store_true") parser.add_argument("--plexer-wrap", action="store_true", help="internal: invoked by the bashrc plexer hook, not meant to be run by hand") + parser.add_argument("--mplex", nargs=argparse.REMAINDER, default=None, metavar="COMMAND", help="terminal multiplexer: any mplex command (new-session, attach-session, list-keys ...); bare --mplex starts a session. Config: ~/.tai/mplex.toml") parser.add_argument("--demo", action="store_true", help="run the fullscreen TUI framework showcase") parser.add_argument("--demo-shot", action="store_true", help="print one composed demo frame as text and exit") parser.add_argument("--tui", action="store_true", help="run the fullscreen Telegram-style chat UI") @@ -15768,6 +22200,9 @@ def main(argv=None): except ValueError as exc: print(exc, file=sys.stderr) return 2 + if args.mplex is not None: + home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") + return MplexCli.main(home, args.mplex) if args.plexer_wrap: home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") profile = args.profile or "default" diff --git a/test_tai.py b/test_tai.py index e2b6fd7..a9e9d5d 100644 --- a/test_tai.py +++ b/test_tai.py @@ -7156,5 +7156,1870 @@ class UpdateModelsTests(unittest.TestCase): self.assertTrue(tai.build_parser().parse_args(["--update-models"]).update_models) + +class MplexKeyTests(unittest.TestCase): + def test_canonical_names(self): + K = tai.MplexKeys + self.assertEqual(K.canonical("C-b"), "C-b") + self.assertEqual(K.canonical("^A"), "C-a") + self.assertEqual(K.canonical("M-C-b"), "C-M-b") + self.assertEqual(K.canonical("PageUp"), "PPage") + self.assertEqual(K.canonical("Delete"), "DC") + self.assertEqual(K.canonical("S-a"), "A") + self.assertEqual(K.canonical("C-m"), "Enter") + self.assertEqual(K.canonical("C-i"), "Tab") + self.assertEqual(K.canonical(" "), "Space") + self.assertEqual(K.canonical("S-Up"), "S-Up") + with self.assertRaises(tai.MplexError): + K.canonical("Nonsense") + with self.assertRaises(tai.MplexError): + K.canonical("") + + def test_split_decodes_terminal_bytes(self): + tokens, rest = tai.MplexKeys.split(b"\x02c\x1b[A\x1bx\x1b[1;5A\x1b[5~\x1bOP\x1b[1;3D\x1b[Z\x00\x7f \r\n") + self.assertEqual(rest, b"") + self.assertEqual([name for name, _raw in tokens], + ["C-b", "c", "Up", "M-x", "C-Up", "PPage", "F1", "M-Left", "BTab", "C-Space", "BSpace", "Space", "Enter", "C-j"]) + + def test_lone_escape_waits_for_final(self): + self.assertEqual(tai.MplexKeys.split(b"\x1b", False), ([], b"\x1b")) + self.assertEqual(tai.MplexKeys.split(b"\x1b", True), ([("Escape", b"\x1b")], b"")) + tokens, rest = tai.MplexKeys.split(b"\x1b[", True) + self.assertEqual([name for name, _raw in tokens], ["Escape", "["]) + + def test_partial_utf8_waits(self): + tokens, rest = tai.MplexKeys.split(b"\xc3", False) + self.assertEqual((tokens, rest), ([], b"\xc3")) + tokens, rest = tai.MplexKeys.split(b"\xc3\xa9", False) + self.assertEqual(tokens, [("é", b"\xc3\xa9")]) + + def test_to_bytes_round_trips_names(self): + K = tai.MplexKeys + for name in ("C-b", "M-x", "C-Up", "S-Up", "M-Left", "F5", "PPage", "Enter", "Space", "BSpace", "Tab", "C-Space", "C-M-b", "F1"): + tokens, _rest = K.split(K.to_bytes(name), True) + self.assertEqual([item[0] for item in tokens], [name], name) + + def test_application_cursor_form(self): + self.assertEqual(tai.MplexKeys.app_cursor_form(b"\x1b[A"), b"\x1bOA") + self.assertEqual(tai.MplexKeys.app_cursor_form(b"\x1b[1;5A"), b"\x1b[1;5A") + self.assertEqual(tai.MplexKeys.to_bytes("Up", app_cursor=True), b"\x1bOA") + + +class MplexCommandParserTests(unittest.TestCase): + def test_quotes_blocks_and_separators(self): + parsed = tai.MplexCommandParser.parse('command-prompt -I "#S" { rename-session "%%" } ; new-window -n \'a b\'') + self.assertEqual(parsed, [["command-prompt", "-I", "#S", 'rename-session "%%"'], ["new-window", "-n", "a b"]]) + + def test_escaped_semicolon_stays_a_word(self): + self.assertEqual(tai.MplexCommandParser.parse(r"bind x kill-pane \; kill-window"), [["bind", "x", "kill-pane", ";", "kill-window"]]) + + def test_newlines_separate_commands(self): + self.assertEqual(tai.MplexCommandParser.parse("a\nb c"), [["a"], ["b", "c"]]) + + def test_unterminated_input_is_an_error(self): + for bad in ('x "abc', "x 'abc", "x { abc"): + with self.assertRaises(tai.MplexError): + tai.MplexCommandParser.parse(bad) + + def test_quote_round_trip(self): + for word in ("plain", "with space", 'say "hi"', "a;b", "", "$HOME"): + self.assertEqual(tai.MplexCommandParser.parse("x " + tai.MplexCommandParser.quote(word))[0][1:], [word]) + + def test_args_parse(self): + args = tai.MplexArgs.parse("t", "bdt:l:", ["-bd", "-t", "foo", "-l5", "rest", "-x"], 0, None) + self.assertTrue(args.has("b") and args.has("d")) + self.assertEqual(args.get("t"), "foo") + self.assertEqual(args.int("l"), 5) + self.assertEqual(args.positional, ["rest", "-x"]) + with self.assertRaises(tai.MplexError): + tai.MplexArgs.parse("t", "b", ["-z"], 0, None) + with self.assertRaises(tai.MplexError): + tai.MplexArgs.parse("t", "t:", ["-t"], 0, None) + with self.assertRaises(tai.MplexError): + tai.MplexArgs.parse("t", "", ["a", "b"], 0, 1) + + +class MplexDefaultsTests(unittest.TestCase): + def test_every_default_binding_parses_and_resolves(self): + unsupported = set() + for table, key, command, repeat, note in tai.MPLEX_DEFAULT_KEYS: + tai.MplexKeys.canonical(key) + for words in tai.MplexCommandParser.parse(command): + try: + tai.MplexCommands.resolve(words[0]) + except tai.MplexError as exc: + self.assertIn("not supported", str(exc), command) + unsupported.add(words[0]) + self.assertEqual(unsupported, {"display-menu", "customize-mode"}) + + def test_tmux_default_prefix_table(self): + config = tai.MplexConfig() + expect = {"c": "new-window", "d": "detach-client", "n": "next-window", "p": "previous-window", "[": "copy-mode", "]": "paste-buffer -p", + "z": "resize-pane -Z", "%": "split-window -h", "\"": "split-window", "x": 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', + "C-b": "send-prefix", "Space": "next-layout", "M-5": "select-layout tiled", "C-z": "suspend-client", "t": "clock-mode"} + for key, command in expect.items(): + self.assertEqual(config.lookup("prefix", key).command, command, key) + self.assertTrue(config.lookup("prefix", "Up").repeat) + self.assertTrue(config.lookup("prefix", "C-Up").repeat) + self.assertFalse(config.lookup("prefix", "c").repeat) + self.assertEqual(config.options.get("prefix"), "C-b") + + def test_copy_mode_tables_present(self): + config = tai.MplexConfig() + self.assertEqual(config.lookup("copy-mode", "C-w").command, "send-keys -X copy-pipe-and-cancel") + self.assertEqual(config.lookup("copy-mode-vi", "Space").command, "send-keys -X begin-selection") + self.assertEqual(config.lookup("copy-mode-vi", "j").command, "send-keys -X cursor-down") + self.assertGreaterEqual(len(config.tables["copy-mode"]), 60) + self.assertGreaterEqual(len(config.tables["copy-mode-vi"]), 75) + + def test_every_copy_mode_command_exists(self): + config = tai.MplexConfig() + for table in ("copy-mode", "copy-mode-vi"): + for key, binding in config.tables[table].items(): + for words in tai.MplexCommandParser.parse(binding.command): + if words[:2] == ["send-keys", "-X"]: + self.assertTrue(hasattr(tai.MplexCopyMode, "c_" + words[2].replace("-", "_")), words) + + +class MplexConfigTests(unittest.TestCase): + def load(self, text): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "mplex.toml") + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return tai.MplexConfig.load(path) + + def test_missing_file_is_defaults(self): + config = tai.MplexConfig.load("/nonexistent/mplex.toml") + self.assertEqual(config.errors, []) + self.assertEqual(config.options.get("prefix"), "C-b") + + def test_options_and_rebinding(self): + config = self.load('[options]\nprefix = "C-a"\nbase-index = 1\nstatus = false\nmode-keys = "vi"\n' + '[keys.prefix]\n"|" = "split-window -h"\nc = false\nr = { command = "source-file x", repeat = true, note = "reload" }\n' + '[keys.root]\n"M-Left" = "previous-window"\n') + self.assertEqual(config.errors, []) + self.assertEqual(config.options.get("prefix"), "C-a") + self.assertEqual(config.options.get("base-index"), 1) + self.assertFalse(config.options.get("status")) + self.assertEqual(config.options.get("mode-keys"), "vi") + self.assertEqual(config.lookup("prefix", "|").command, "split-window -h") + self.assertIsNone(config.lookup("prefix", "c")) + self.assertTrue(config.lookup("prefix", "r").repeat) + self.assertEqual(config.lookup("prefix", "r").note, "reload") + self.assertEqual(config.lookup("root", "M-Left").command, "previous-window") + + def test_clear_table(self): + config = self.load('[keys]\nclear = ["prefix"]\n[keys.prefix]\nc = "new-window"\n') + self.assertEqual(list(config.tables["prefix"]), ["c"]) + + def test_bad_entries_are_skipped_loudly(self): + config = self.load('[options]\nbogus = 1\nbase-index = "x"\nescape-time = 99999999\n' + '[keys.prefix]\nx = "no-such-command"\nNonsenseKey = "new-window"\ny = 5\nz = { command = "new-window", wat = 1 }\n' + '[nothing]\na = 1\n') + self.assertEqual(config.skipped, 8) + joined = "\n".join(config.errors) + for needle in ("options.bogus", "options.base-index", "options.escape-time", "keys.prefix.x", "keys.prefix.NonsenseKey", "keys.prefix.y", "keys.prefix.z", "[nothing]"): + self.assertIn(needle, joined) + self.assertEqual(config.lookup("prefix", "c").command, "new-window") + + def test_invalid_toml_keeps_defaults(self): + config = self.load("this is [ not toml") + self.assertEqual(config.skipped, 1) + self.assertIn("not valid TOML", config.errors[0]) + self.assertEqual(config.lookup("prefix", "c").command, "new-window") + + def test_error_list_is_capped(self): + text = "[options]\n" + "".join("bad%d = 1\n" % n for n in range(200)) + config = self.load(text) + self.assertEqual(len(config.errors), tai.MplexConfig.ERRORS_MAX) + self.assertEqual(config.skipped, 200) + + def test_options_validation(self): + options = tai.MplexOptions() + self.assertEqual(options.set("status-position", "top"), "top") + with self.assertRaises(tai.MplexError): + options.set("status-position", "middle") + with self.assertRaises(tai.MplexError): + options.set("history-limit", tai.MPLEX_HISTORY_CAP + 1) + with self.assertRaises(tai.MplexError): + options.set("mouse", "on") + self.assertEqual(options.set("prefix", "C-a"), "C-a") + self.assertEqual(options.set("prefix2", "None"), "None") + child = tai.MplexOptions(options) + self.assertEqual(child.get("status-position"), "top") + child.set("status-position", "bottom") + self.assertEqual(options.get("status-position"), "top") + child.unset("status-position") + self.assertEqual(child.get("status-position"), "top") + child.toggle("status") + self.assertFalse(child.get("status")) + + +class MplexFormatTests(unittest.TestCase): + def expand(self, text, **values): + return tai.MplexFormat.expand(text, lambda name: values.get(name)) + + def test_variables_and_shorthands(self): + self.assertEqual(self.expand("[#S] #I:#W#F", session_name="main", window_index="2", window_name="vim", window_flags="*"), "[main] 2:vim*") + self.assertEqual(self.expand("##x #{session_name}", session_name="s"), "#x s") + + def test_conditionals(self): + self.assertEqual(self.expand("#{?window_flags,#{window_flags},-}", window_flags="*"), "*") + self.assertEqual(self.expand("#{?window_flags,#{window_flags},-}", window_flags=""), "-") + self.assertEqual(self.expand("#{?a,1,#{?b,2,3}}", a="0", b="1"), "2") + self.assertEqual(self.expand("#{?#{==:#{x},ok},yes,no}", x="ok"), "yes") + self.assertEqual(self.expand("#{?x,a#,b,c}", x="1"), "a,b") + + def test_modifiers(self): + self.assertEqual(self.expand("#{=5:title}", title="abcdefghij"), "abcde") + self.assertEqual(self.expand("#{=-3:title}", title="abcdefghij"), "hij") + self.assertEqual(self.expand("#{=/4/...:title}", title="abcdefghij"), "abcd...") + self.assertEqual(self.expand("#{n:title}", title="abc"), "3") + self.assertEqual(self.expand("#{b:path}", path="/a/b/c.txt"), "c.txt") + self.assertEqual(self.expand("#{E:fmt}", fmt="#{name}", name="x"), "x") + self.assertEqual(self.expand("#{m:w*,#{n}}", n="word"), "1") + self.assertEqual(self.expand("#{s/a/b/:v}", v="banana"), "bbnbnb") + self.assertEqual(self.expand("#{e|+:1,2}"), "3") + self.assertEqual(self.expand("#{&&:1,0}"), "0") + self.assertEqual(self.expand("#{!:0}"), "1") + + def test_styles_pass_through_and_strip(self): + text = self.expand("#[fg=red]x#[default]") + self.assertEqual(text, "#[fg=red]x#[default]") + self.assertEqual(tai.MplexFormat.strip_styles(text), "x") + + def test_unknown_and_deep_recursion_are_empty(self): + self.assertEqual(self.expand("a#{nope}b"), "ab") + self.assertEqual(self.expand("#{E:r}", r="#{E:r}"), "") + + def test_time_expansion(self): + self.assertEqual(tai.MplexFormat.with_time("%Y", 0), time.strftime("%Y", time.localtime(0))) + self.assertEqual(tai.MplexFormat.with_time("no percent"), "no percent") + + def test_status_cells_apply_style_markers(self): + base = (None, "42", 0) + cells = tai.MplexStatusText.cells("a#[fg=red,bold]b#[default]c", base) + self.assertEqual(cells[0], ("a", None, "42", 0)) + self.assertEqual(cells[1], ("b", "31", "42", 1)) + self.assertEqual(cells[2], ("c", None, "42", 0)) + + +class MplexStyleTests(unittest.TestCase): + def test_parse(self): + self.assertEqual(tai.MplexStyle.parse("bg=green,fg=black"), ("30", "42", 0)) + self.assertEqual(tai.MplexStyle.parse("fg=colour123,bold"), ("38;5;123", None, 1)) + self.assertEqual(tai.MplexStyle.parse("fg=#ff0010"), ("38;2;255;0;16", None, 0)) + self.assertEqual(tai.MplexStyle.parse("fg=brightred"), ("91", None, 0)) + self.assertEqual(tai.MplexStyle.parse("bold,nobold"), (None, None, 0)) + self.assertEqual(tai.MplexStyle.parse("noattr,bg=yellow,fg=black"), ("30", "43", 0)) + with self.assertRaises(tai.MplexError): + tai.MplexStyle.parse("fg=nocolour") + + +class MplexLayoutTests(unittest.TestCase): + def build(self, count, width=80, height=24): + panes = [tai.MplexPane(n, 1, 1, 10, tempfile.gettempdir()) for n in range(count)] + root = tai.MplexLayoutNode("pane", pane=panes[0], w=width, h=height) + leaf = root + return panes, root, leaf + + def test_split_matches_tmux_sizes(self): + panes, root, leaf = self.build(2) + root, fresh = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((leaf.w, fresh.w), (40, 39)) + self.assertEqual((fresh.x, fresh.y, fresh.h), (41, 0, 24)) + root, low = tai.MplexLayout.split(root, fresh, "tb", False, tai.MplexPane(3, 1, 1, 10, tempfile.gettempdir())) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((fresh.h, low.h, low.y), (12, 11, 13)) + + def test_split_before_and_size(self): + panes, root, leaf = self.build(2) + root, fresh = tai.MplexLayout.split(root, leaf, "tb", True, panes[1], size=5) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((fresh.y, fresh.h, leaf.y, leaf.h), (0, 5, 6, 18)) + + def test_split_without_room(self): + panes, root, leaf = self.build(2, width=2, height=24) + with self.assertRaises(tai.MplexError): + tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) + + def test_remove_gives_space_back_and_collapses(self): + panes, root, leaf = self.build(3) + root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + root, third = tai.MplexLayout.split(root, second, "tb", False, panes[2]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + root = tai.MplexLayout.remove(root, third) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual(second.h, 24) + root = tai.MplexLayout.remove(root, second) + self.assertIs(root, leaf) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((leaf.w, leaf.h), (80, 24)) + self.assertIsNone(tai.MplexLayout.remove(root, leaf)) + + def test_resize_moves_the_border(self): + panes, root, leaf = self.build(2) + root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + tai.MplexLayout.resize_by(leaf, "lr", 5, True) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((leaf.w, second.w), (45, 34)) + tai.MplexLayout.resize_by(second, "lr", -3, False) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((leaf.w, second.w), (42, 37)) + tai.MplexLayout.resize_by(leaf, "lr", 1000, True) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual(second.w, 1) + with self.assertRaises(tai.MplexError): + tai.MplexLayout.resize_by(leaf, "tb", 1, True) + + def test_presets_match_tmux_3_6(self): + """Expected sizes were produced by the installed tmux 3.6 for an 80x24 window (new-session -d -x 80 -y 24).""" + panes = [tai.MplexPane(n, 1, 1, 10, tempfile.gettempdir()) for n in range(5)] + for name, count, expect in ( + ("even-horizontal", 3, [(26, 24)] * 3), + ("even-horizontal", 4, [(20, 24), (19, 24), (19, 24), (19, 24)]), + ("even-vertical", 2, [(80, 12), (80, 11)]), + ("even-vertical", 3, [(80, 8), (80, 7), (80, 7)]), + ("main-vertical", 2, [(78, 24), (1, 24)]), + ("main-vertical", 3, [(78, 24), (1, 12), (1, 11)]), + ("main-horizontal", 3, [(80, 22), (40, 1), (39, 1)]), + ("tiled", 2, [(80, 11), (80, 12)]), + ("tiled", 3, [(39, 11), (40, 11), (80, 12)]), + ("tiled", 4, [(39, 11), (40, 11), (39, 12), (40, 12)]), + ): + root = tai.MplexLayout.preset(name, panes[:count], 80, 24, 80, 24) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + sizes = [(leaf.w, leaf.h) for leaf in root.leaves()] + self.assertEqual(sizes, expect, "%s %d" % (name, count)) + + def test_mirrored_presets_keep_pane_order_and_flip_positions(self): + panes = [tai.MplexPane(n, 1, 1, 10, tempfile.gettempdir()) for n in range(3)] + root = tai.MplexLayout.preset("main-vertical-mirrored", panes, 80, 24, 80, 24) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual([leaf.pane.id for leaf in root.leaves()], [0, 1, 2]) + self.assertEqual([(leaf.x, leaf.y, leaf.w, leaf.h) for leaf in root.leaves()], [(2, 0, 78, 24), (0, 0, 1, 12), (0, 13, 1, 11)]) + root = tai.MplexLayout.preset("main-horizontal-mirrored", panes, 80, 24, 80, 24) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual([(leaf.x, leaf.y, leaf.w, leaf.h) for leaf in root.leaves()], [(0, 2, 80, 22), (0, 0, 40, 1), (41, 0, 39, 1)]) + + def test_split_before_gives_the_new_pane_the_larger_half(self): + panes, root, leaf = self.build(2, height=24) + root, fresh = tai.MplexLayout.split(root, leaf, "lr", True, panes[1]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((fresh.w, leaf.w, fresh.x, leaf.x), (40, 39, 0, 41)) + + def test_resize_to_moves_the_border_the_pane_has(self): + panes, root, leaf = self.build(2) + root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + tai.MplexLayout.resize_to(second, "lr", 20) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((leaf.w, second.w), (59, 20)) + tai.MplexLayout.resize_to(leaf, "lr", 30) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + self.assertEqual((leaf.w, second.w), (30, 49)) + + def test_neighbour_by_direction_wraps(self): + panes, root, leaf = self.build(3) + root, second = tai.MplexLayout.split(root, leaf, "lr", False, panes[1]) + root, third = tai.MplexLayout.split(root, second, "tb", False, panes[2]) + tai.MplexLayout.assign(root, 0, 0, 80, 24) + for node in root.leaves(): + node.pane.place(node.x, node.y, node.w, node.h) + near = lambda current, direction: tai.MplexLayout.neighbour(root_panes, current, direction, 80, 24) + root_panes = [node.pane for node in root.leaves()] + self.assertIs(near(panes[0], "R"), panes[1]) + self.assertIs(near(panes[1], "D"), panes[2]) + self.assertIs(near(panes[2], "U"), panes[1]) + self.assertIs(near(panes[2], "L"), panes[0]) + self.assertIs(near(panes[0], "L"), panes[1] if panes[1].last_active >= panes[2].last_active else panes[2]) + self.assertIsNone(tai.MplexLayout.neighbour([panes[0]], panes[0], "U", 80, 24)) + + +class MplexCanvasTests(unittest.TestCase): + def test_encode_round_trips_through_a_terminal(self): + canvas = tai.MplexCanvas(5, 20) + canvas.put(2, 1, "hello", ("31", "42", 1)) + canvas.put(0, 4, "bottom") + canvas.cursor = (3, 1) + data, snapshot = canvas.encode(None) + screen = tai.TuiVtScreen(5, 20) + screen.feed(data) + lines = screen.text().split("\n") + self.assertEqual(lines[1], " hello") + self.assertEqual(lines[4], "bottom") + self.assertEqual(screen.grid[1][2], ("h", "31", "42", 1)) + self.assertEqual((screen.cx, screen.cy), (3, 1)) + + def test_second_frame_repaints_only_changed_rows(self): + canvas = tai.MplexCanvas(4, 10) + canvas.put(0, 0, "aaaa") + canvas.put(0, 3, "dddd") + data, snapshot = canvas.encode(None) + canvas.put(0, 3, "DDDD") + data2, _snapshot2 = canvas.encode(snapshot) + self.assertIn(b"\x1b[4;1H", data2) + self.assertNotIn(b"\x1b[1;1H", data2) + self.assertNotIn(b"\x1b[2J", data2) + self.assertIn(b"\x1b[2J", data) + + def test_border_glyphs_follow_connectivity(self): + glyph = tai.MplexCanvas.border_char + self.assertEqual(glyph(True, True, False, False, "single"), "│") + self.assertEqual(glyph(False, False, True, True, "single"), "─") + self.assertEqual(glyph(True, True, False, True, "single"), "├") + self.assertEqual(glyph(True, True, True, True, "single"), "┼") + self.assertEqual(glyph(False, True, True, True, "double"), "╦") + self.assertEqual(glyph(True, False, True, True, "single"), "┴") + + def test_blit_clips_and_fill(self): + canvas = tai.MplexCanvas(3, 6) + canvas.blit(4, 1, [[("x", None, None, 0)] * 5], 5) + self.assertEqual(canvas.cells[1][4][0], "x") + self.assertEqual(canvas.cells[1][5][0], "x") + canvas.fill(0, 0, 3, 1, (None, "41", 0), "#") + self.assertEqual(canvas.cells[0][2], ("#", None, "41", 0)) + + +class MplexVtExtensionTests(unittest.TestCase): + def test_scrollback_collects_scrolled_rows(self): + screen = tai.TuiVtScreen(3, 10) + screen.history = tai.collections.deque(maxlen=4) + screen.feed(b"1\r\n2\r\n3\r\n4\r\n5\r\n") + self.assertEqual(["".join(c[0] if c else " " for c in row).strip() for row in screen.history], ["1", "2", "3"]) + self.assertEqual(screen.history_pushed, 3) + screen.feed(b"6\r\n7\r\n8\r\n") + self.assertEqual(len(screen.history), 4) + self.assertEqual(screen.history_pushed, 6) + + def test_scroll_region_and_alt_screen_do_not_feed_history(self): + screen = tai.TuiVtScreen(5, 10) + screen.history = tai.collections.deque(maxlen=50) + screen.feed(b"\x1b[2;4r\x1b[4;1H\n\n\n") + self.assertEqual(len(screen.history), 0) + screen.feed(b"\x1b[r\x1b[?1049h\x1b[5;1H\n\n\n") + self.assertEqual(len(screen.history), 0) + + def test_device_replies_and_modes(self): + screen = tai.TuiVtScreen(5, 10) + screen.feed(b"\x1b[3;4H\x1b[6n\x1b[c\x1b[5n") + self.assertEqual(screen.take_replies(), b"\x1b[3;4R\x1b[?1;2c\x1b[0n") + self.assertEqual(screen.take_replies(), b"") + screen.feed(b"\x1b[?1h\x1b[?2004h") + self.assertTrue(screen.app_cursor and screen.bracketed_paste) + screen.feed(b"\x1b[?1l\x1b[?2004l") + self.assertFalse(screen.app_cursor or screen.bracketed_paste) + + def test_title_graphics_repeat_and_extra_attributes(self): + screen = tai.TuiVtScreen(3, 10) + screen.feed(b"\x1b]0;my title\x07\x1b(0lqk\x1b(Bx\x1b[3;5;8mA\x1b[0mB\x1b[3b") + self.assertEqual(screen.title, "my title") + self.assertEqual("".join(cell[0] for cell in screen.grid[0][:4]), "┌─┐x") + self.assertEqual(screen.grid[0][4][3], tai.TuiVtScreen.ATTR_ITALIC | tai.TuiVtScreen.ATTR_BLINK | tai.TuiVtScreen.ATTR_HIDDEN) + self.assertEqual(screen.grid[0][5][3], 0) + self.assertEqual("".join(cell[0] for cell in screen.grid[0][5:9]), "BBBB") + + +class MplexStubServer: + """Just enough server for pane modes: options, bindings, a copy sink.""" + + def __init__(self): + self.config = tai.MplexConfig() + self.options = tai.MplexOptions() + self.copied = [] + self.dirty = 0 + + def pane_options(self, pane): + return self.options + + def mark_dirty(self, window=None): + self.dirty += 1 + + def store_copy(self, text, command="", append=False): + self.copied.append((text, command, append)) + + def run_binding(self, client, binding, pane=None): + words = tai.MplexCommandParser.parse(binding.command)[0] + pane.top_mode().command(words[2], words[3:], client) + + +# Expected copy mode results produced by the installed tmux 3.6 (an attached client, a 30x6 window, mode-keys +# emacs or vi): (mode-keys, "cmd;cmd*N;...", repeat count of the last command, "cx cy scroll_position") and +# (mode-keys, commands, text that ended up in the paste buffer). "name*N" repeats a command N times. +MPLEX_TMUX_CASES = ( + ('emacs', 'cursor-up', 1, '25 4 0'), + ('emacs', 'cursor-down', 1, '0 5 0'), + ('emacs', 'cursor-left', 1, '25 4 0'), + ('emacs', 'cursor-right', 1, '0 5 0'), + ('emacs', 'start-of-line', 1, '0 5 0'), + ('emacs', 'end-of-line', 1, '0 5 0'), + ('emacs', 'back-to-indentation', 1, '0 5 0'), + ('emacs', 'top-line', 1, '0 0 0'), + ('emacs', 'middle-line', 1, '0 2 0'), + ('emacs', 'bottom-line', 1, '0 5 0'), + ('emacs', 'history-top', 1, '0 0 13'), + ('emacs', 'history-bottom', 1, '0 5 0'), + ('emacs', 'page-up', 1, '22 5 4'), + ('emacs', 'page-down', 1, '0 5 0'), + ('emacs', 'halfpage-up', 1, '1 5 3'), + ('emacs', 'halfpage-down', 1, '0 5 0'), + ('emacs', 'scroll-up', 1, '25 5 1'), + ('emacs', 'scroll-down', 1, '0 5 0'), + ('emacs', 'next-word', 1, '1 5 0'), + ('emacs', 'next-word-end', 1, '1 5 0'), + ('emacs', 'next-space', 1, '1 5 0'), + ('emacs', 'next-space-end', 1, '1 5 0'), + ('emacs', 'previous-word', 1, '23 4 0'), + ('emacs', 'previous-space', 1, '23 4 0'), + ('emacs', 'next-paragraph', 1, '0 5 0'), + ('emacs', 'previous-paragraph', 1, '0 3 0'), + ('emacs', 'scroll-middle', 1, '0 5 0'), + ('emacs', 'scroll-top', 1, '0 5 0'), + ('emacs', 'scroll-bottom', 1, '0 5 0'), + ('emacs', 'cursor-centre-vertical', 1, '0 3 0'), + ('emacs', 'jump-forward o', 1, '0 5 0'), + ('emacs', 'jump-backward e', 1, '0 5 0'), + ('emacs', 'jump-to-forward a', 1, '0 5 0'), + ('emacs', 'jump-to-backward e', 1, '0 5 0'), + ('emacs', 'goto-line 5', 1, '0 5 5'), + ('emacs', 'search-forward ba', 1, '6 5 10'), + ('emacs', 'search-backward ba', 1, '9 5 10'), + ('emacs', 'search-forward (b', 1, '0 5 0'), + ('emacs', 'search-backward to', 1, '5 4 13'), + ('emacs', 'cursor-down', 3, '0 5 0'), + ('emacs', 'cursor-right', 3, '0 5 0'), + ('emacs', 'next-word', 3, '1 5 0'), + ('emacs', 'previous-word', 3, '13 4 0'), + ('emacs', 'next-word-end', 3, '1 5 0'), + ('emacs', 'page-up', 3, '0 5 12'), + ('emacs', 'scroll-up', 3, '1 5 3'), + ('emacs', 'history-top;cursor-up', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down', 1, '0 1 13'), + ('emacs', 'history-top;cursor-left', 1, '0 0 13'), + ('emacs', 'history-top;cursor-right', 1, '1 0 13'), + ('emacs', 'history-top;start-of-line', 1, '0 0 13'), + ('emacs', 'history-top;end-of-line', 1, '23 0 13'), + ('emacs', 'history-top;back-to-indentation', 1, '0 0 13'), + ('emacs', 'history-top;top-line', 1, '0 0 13'), + ('emacs', 'history-top;middle-line', 1, '0 2 13'), + ('emacs', 'history-top;bottom-line', 1, '0 5 13'), + ('emacs', 'history-top*2', 1, '0 0 13'), + ('emacs', 'history-top;history-bottom', 1, '0 5 0'), + ('emacs', 'history-top;page-up', 1, '0 0 13'), + ('emacs', 'history-top;page-down', 1, '0 0 9'), + ('emacs', 'history-top;halfpage-up', 1, '0 0 13'), + ('emacs', 'history-top;halfpage-down', 1, '0 0 10'), + ('emacs', 'history-top;scroll-up', 1, '0 0 13'), + ('emacs', 'history-top;scroll-down', 1, '0 0 12'), + ('emacs', 'history-top;next-word', 1, '6 0 13'), + ('emacs', 'history-top;next-word-end', 1, '5 0 13'), + ('emacs', 'history-top;next-space', 1, '6 0 13'), + ('emacs', 'history-top;next-space-end', 1, '5 0 13'), + ('emacs', 'history-top;previous-word', 1, '0 0 13'), + ('emacs', 'history-top;previous-space', 1, '0 0 13'), + ('emacs', 'history-top;next-paragraph', 1, '0 2 13'), + ('emacs', 'history-top;previous-paragraph', 1, '0 0 13'), + ('emacs', 'history-top;scroll-middle', 1, '0 0 13'), + ('emacs', 'history-top;scroll-top', 1, '0 0 13'), + ('emacs', 'history-top;scroll-bottom', 1, '0 0 13'), + ('emacs', 'history-top;cursor-centre-vertical', 1, '0 3 13'), + ('emacs', 'history-top;jump-forward o', 1, '0 0 13'), + ('emacs', 'history-top;jump-backward e', 1, '0 0 13'), + ('emacs', 'history-top;jump-to-forward a', 1, '3 0 13'), + ('emacs', 'history-top;jump-to-backward e', 1, '0 0 13'), + ('emacs', 'history-top;goto-line 5', 1, '0 0 5'), + ('emacs', 'history-top;search-forward ba', 1, '6 5 10'), + ('emacs', 'history-top;search-backward ba', 1, '9 5 10'), + ('emacs', 'history-top;search-forward (b', 1, '0 0 13'), + ('emacs', 'history-top;search-backward to', 1, '5 4 13'), + ('emacs', 'history-top;cursor-down', 3, '0 3 13'), + ('emacs', 'history-top;cursor-right', 3, '3 0 13'), + ('emacs', 'history-top;next-word', 3, '11 0 13'), + ('emacs', 'history-top;previous-word', 3, '0 0 13'), + ('emacs', 'history-top;next-word-end', 3, '11 0 13'), + ('emacs', 'history-top;page-up', 3, '0 0 13'), + ('emacs', 'history-top;scroll-up', 3, '0 0 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-up', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 1, '3 4 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-left', 1, '2 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*4', 1, '4 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;start-of-line', 1, '0 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;end-of-line', 1, '27 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;back-to-indentation', 1, '0 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;top-line', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;middle-line', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;bottom-line', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;history-top', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;history-bottom', 1, '0 5 0'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;page-up', 1, '3 0 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;page-down', 1, '3 3 9'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;halfpage-up', 1, '3 0 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;halfpage-down', 1, '0 3 10'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 1, '3 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-down', 1, '3 3 12'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word', 1, '4 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 1, '4 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-space', 1, '11 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-space-end', 1, '10 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-word', 1, '0 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-space', 1, '0 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-paragraph', 1, '0 5 12'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-paragraph', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-middle', 1, '3 2 12'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-top', 1, '3 0 10'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-bottom', 1, '3 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-centre-vertical', 1, '3 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-forward o', 1, '11 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-backward e', 1, '1 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-to-forward a', 1, '4 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;jump-to-backward e', 1, '2 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;goto-line 5', 1, '3 3 5'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-forward ba', 1, '6 5 10'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-backward ba', 1, '9 5 10'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-forward (b', 1, '3 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;search-backward to', 1, '5 4 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 3, '0 5 12'), + ('emacs', 'history-top;cursor-down*3;cursor-right*4', 3, '6 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word', 3, '11 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;previous-word', 3, '20 1 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 3, '10 3 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;page-up', 3, '3 0 13'), + ('emacs', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 3, '3 3 13'), + ('emacs', 'page-up;cursor-up', 1, '12 4 4'), + ('emacs', 'page-up;cursor-down', 1, '1 5 3'), + ('emacs', 'page-up;cursor-left', 1, '21 5 4'), + ('emacs', 'page-up;cursor-right', 1, '0 5 3'), + ('emacs', 'page-up;start-of-line', 1, '0 5 4'), + ('emacs', 'page-up;end-of-line', 1, '22 5 4'), + ('emacs', 'page-up;back-to-indentation', 1, '0 5 4'), + ('emacs', 'page-up;top-line', 1, '0 0 4'), + ('emacs', 'page-up;middle-line', 1, '0 2 4'), + ('emacs', 'page-up;bottom-line', 1, '0 5 4'), + ('emacs', 'page-up;history-top', 1, '0 0 13'), + ('emacs', 'page-up;history-bottom', 1, '0 5 0'), + ('emacs', 'page-up*2', 1, '0 5 8'), + ('emacs', 'page-up;page-down', 1, '0 5 0'), + ('emacs', 'page-up;halfpage-up', 1, '23 5 7'), + ('emacs', 'page-up;halfpage-down', 1, '25 5 1'), + ('emacs', 'page-up;scroll-up', 1, '12 5 5'), + ('emacs', 'page-up;scroll-down', 1, '1 5 3'), + ('emacs', 'page-up;next-word', 1, '0 5 3'), + ('emacs', 'page-up;next-word-end', 1, '1 5 3'), + ('emacs', 'page-up;next-space', 1, '0 5 3'), + ('emacs', 'page-up;next-space-end', 1, '1 5 3'), + ('emacs', 'page-up;previous-word', 1, '12 5 4'), + ('emacs', 'page-up;previous-space', 1, '12 5 4'), + ('emacs', 'page-up;next-paragraph', 1, '0 5 2'), + ('emacs', 'page-up;previous-paragraph', 1, '0 1 4'), + ('emacs', 'page-up;scroll-middle', 1, '22 2 1'), + ('emacs', 'page-up;scroll-top', 1, '22 5 4'), + ('emacs', 'page-up;scroll-bottom', 1, '22 5 4'), + ('emacs', 'page-up;cursor-centre-vertical', 1, '22 3 4'), + ('emacs', 'page-up;jump-forward o', 1, '22 5 4'), + ('emacs', 'page-up;jump-backward e', 1, '21 5 4'), + ('emacs', 'page-up;jump-to-forward a', 1, '22 5 4'), + ('emacs', 'page-up;jump-to-backward e', 1, '16 5 4'), + ('emacs', 'page-up;goto-line 5', 1, '22 5 5'), + ('emacs', 'page-up;search-forward ba', 1, '6 5 10'), + ('emacs', 'page-up;search-backward ba', 1, '9 5 10'), + ('emacs', 'page-up;search-forward (b', 1, '22 5 4'), + ('emacs', 'page-up;search-backward to', 1, '5 4 13'), + ('emacs', 'page-up;cursor-down', 3, '25 5 1'), + ('emacs', 'page-up;cursor-right', 3, '0 5 2'), + ('emacs', 'page-up;next-word', 3, '5 5 1'), + ('emacs', 'page-up;previous-word', 3, '0 5 4'), + ('emacs', 'page-up;next-word-end', 3, '9 5 1'), + ('emacs', 'page-up*2', 3, '27 1 13'), + ('emacs', 'page-up;scroll-up', 3, '23 5 7'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-up', 1, '11 3 4'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-down', 1, '22 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-left', 1, '11 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-right', 1, '0 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;start-of-line', 1, '0 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line*2', 1, '12 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;back-to-indentation', 1, '0 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;top-line', 1, '0 0 4'), + ('emacs', 'page-up;cursor-up;end-of-line;middle-line', 1, '0 2 4'), + ('emacs', 'page-up;cursor-up;end-of-line;bottom-line', 1, '0 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;history-top', 1, '0 0 13'), + ('emacs', 'page-up;cursor-up;end-of-line;history-bottom', 1, '0 5 0'), + ('emacs', 'page-up;cursor-up;end-of-line;page-up', 1, '12 4 8'), + ('emacs', 'page-up;cursor-up;end-of-line;page-down', 1, '25 4 0'), + ('emacs', 'page-up;cursor-up;end-of-line;halfpage-up', 1, '0 4 7'), + ('emacs', 'page-up;cursor-up;end-of-line;halfpage-down', 1, '0 4 1'), + ('emacs', 'page-up;cursor-up;end-of-line;scroll-up', 1, '11 4 5'), + ('emacs', 'page-up;cursor-up;end-of-line;scroll-down', 1, '22 4 3'), + ('emacs', 'page-up;cursor-up;end-of-line;next-word', 1, '0 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;next-word-end', 1, '5 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;next-space', 1, '0 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;next-space-end', 1, '5 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;previous-word', 1, '8 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;previous-space', 1, '8 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;next-paragraph', 1, '0 5 2'), + ('emacs', 'page-up;cursor-up;end-of-line;previous-paragraph', 1, '0 1 4'), + ('emacs', 'page-up;cursor-up;end-of-line;scroll-middle', 1, '12 2 2'), + ('emacs', 'page-up;cursor-up;end-of-line;scroll-top', 1, '12 0 0'), + ('emacs', 'page-up;cursor-up;end-of-line;scroll-bottom', 1, '12 5 5'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-centre-vertical', 1, '12 3 4'), + ('emacs', 'page-up;cursor-up;end-of-line;jump-forward o', 1, '12 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;jump-backward e', 1, '12 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;jump-to-forward a', 1, '12 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;jump-to-backward e', 1, '12 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;goto-line 5', 1, '12 4 5'), + ('emacs', 'page-up;cursor-up;end-of-line;search-forward ba', 1, '6 5 10'), + ('emacs', 'page-up;cursor-up;end-of-line;search-backward ba', 1, '9 5 10'), + ('emacs', 'page-up;cursor-up;end-of-line;search-forward (b', 1, '12 4 4'), + ('emacs', 'page-up;cursor-up;end-of-line;search-backward to', 1, '5 4 13'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-down', 3, '0 5 2'), + ('emacs', 'page-up;cursor-up;end-of-line;cursor-right', 3, '2 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;next-word', 3, '12 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;previous-word', 3, '10 3 4'), + ('emacs', 'page-up;cursor-up;end-of-line;next-word-end', 3, '22 5 4'), + ('emacs', 'page-up;cursor-up;end-of-line;page-up', 3, '23 0 13'), + ('emacs', 'page-up;cursor-up;end-of-line;scroll-up', 3, '0 4 7'), + ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-up', 1, '11 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-down', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-left', 1, '10 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*12', 1, '12 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;start-of-line', 1, '0 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;end-of-line', 1, '27 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;back-to-indentation', 1, '2 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;top-line', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;middle-line', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;bottom-line', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;history-top', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;history-bottom', 1, '0 5 0'), + ('emacs', 'history-top;cursor-down;cursor-right*11;page-up', 1, '11 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;page-down', 1, '5 1 9'), + ('emacs', 'history-top;cursor-down;cursor-right*11;halfpage-up', 1, '11 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;halfpage-down', 1, '11 1 10'), + ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-up', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-down', 1, '0 1 12'), + ('emacs', 'history-top;cursor-down;cursor-right*11;next-word', 1, '12 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;next-word-end', 1, '12 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;next-space', 1, '14 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;next-space-end', 1, '13 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;previous-word', 1, '2 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;previous-space', 1, '2 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;next-paragraph', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;previous-paragraph', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-middle', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-top', 1, '11 0 12'), + ('emacs', 'history-top;cursor-down;cursor-right*11;scroll-bottom', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;cursor-centre-vertical', 1, '11 3 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;jump-forward o', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;jump-backward e', 1, '8 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;jump-to-forward a', 1, '23 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;jump-to-backward e', 1, '9 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;goto-line 5', 1, '11 1 5'), + ('emacs', 'history-top;cursor-down;cursor-right*11;search-forward ba', 1, '6 5 10'), + ('emacs', 'history-top;cursor-down;cursor-right*11;search-backward ba', 1, '9 5 10'), + ('emacs', 'history-top;cursor-down;cursor-right*11;search-forward (b', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;search-backward to', 1, '5 4 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;next-matching-bracket', 1, '21 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*11;previous-matching-bracket', 1, '2 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-up', 1, '20 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-down', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-left', 1, '19 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*21', 1, '21 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;start-of-line', 1, '0 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;end-of-line', 1, '27 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;back-to-indentation', 1, '2 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;top-line', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;middle-line', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;bottom-line', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;history-top', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;history-bottom', 1, '0 5 0'), + ('emacs', 'history-top;cursor-down;cursor-right*20;page-up', 1, '20 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;page-down', 1, '5 1 9'), + ('emacs', 'history-top;cursor-down;cursor-right*20;halfpage-up', 1, '20 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;halfpage-down', 1, '17 1 10'), + ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-up', 1, '20 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-down', 1, '0 1 12'), + ('emacs', 'history-top;cursor-down;cursor-right*20;next-word', 1, '23 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;next-word-end', 1, '22 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;next-space', 1, '23 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;next-space-end', 1, '22 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;previous-word', 1, '19 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;previous-space', 1, '18 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;next-paragraph', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;previous-paragraph', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-middle', 1, '20 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-top', 1, '20 0 12'), + ('emacs', 'history-top;cursor-down;cursor-right*20;scroll-bottom', 1, '20 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;cursor-centre-vertical', 1, '20 3 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;jump-forward o', 1, '20 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;jump-backward e', 1, '8 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;jump-to-forward a', 1, '23 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;jump-to-backward e', 1, '9 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;goto-line 5', 1, '20 1 5'), + ('emacs', 'history-top;cursor-down;cursor-right*20;search-forward ba', 1, '6 5 10'), + ('emacs', 'history-top;cursor-down;cursor-right*20;search-backward ba', 1, '9 5 10'), + ('emacs', 'history-top;cursor-down;cursor-right*20;search-forward (b', 1, '20 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;search-backward to', 1, '5 4 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;next-matching-bracket', 1, '22 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*20;previous-matching-bracket', 1, '18 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-up', 1, '13 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-down', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-left', 1, '12 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*14', 1, '14 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;start-of-line', 1, '0 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;end-of-line', 1, '27 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;back-to-indentation', 1, '2 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;top-line', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;middle-line', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;bottom-line', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;history-top', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;history-bottom', 1, '0 5 0'), + ('emacs', 'history-top;cursor-down;cursor-right*13;page-up', 1, '13 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;page-down', 1, '5 1 9'), + ('emacs', 'history-top;cursor-down;cursor-right*13;halfpage-up', 1, '13 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;halfpage-down', 1, '13 1 10'), + ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-up', 1, '13 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-down', 1, '0 1 12'), + ('emacs', 'history-top;cursor-down;cursor-right*13;next-word', 1, '14 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;next-word-end', 1, '15 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;next-space', 1, '14 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;next-space-end', 1, '17 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;previous-word', 1, '12 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;previous-space', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;next-paragraph', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;previous-paragraph', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-middle', 1, '13 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-top', 1, '13 0 12'), + ('emacs', 'history-top;cursor-down;cursor-right*13;scroll-bottom', 1, '13 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;cursor-centre-vertical', 1, '13 3 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;jump-forward o', 1, '13 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;jump-backward e', 1, '8 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;jump-to-forward a', 1, '23 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;jump-to-backward e', 1, '9 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;goto-line 5', 1, '13 1 5'), + ('emacs', 'history-top;cursor-down;cursor-right*13;search-forward ba', 1, '6 5 10'), + ('emacs', 'history-top;cursor-down;cursor-right*13;search-backward ba', 1, '9 5 10'), + ('emacs', 'history-top;cursor-down;cursor-right*13;search-forward (b', 1, '13 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;search-backward to', 1, '5 4 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;next-matching-bracket', 1, '16 1 13'), + ('emacs', 'history-top;cursor-down;cursor-right*13;previous-matching-bracket', 1, '11 1 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-up', 1, '0 3 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-down', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-left', 1, '16 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-right', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;start-of-line', 1, '0 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line*2', 1, '17 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;back-to-indentation', 1, '0 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;top-line', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;middle-line', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;bottom-line', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;history-top', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;history-bottom', 1, '0 5 0'), + ('emacs', 'history-top;cursor-down*4;end-of-line;page-up', 1, '0 0 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;page-down', 1, '0 4 9'), + ('emacs', 'history-top;cursor-down*4;end-of-line;halfpage-up', 1, '0 1 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;halfpage-down', 1, '0 4 10'), + ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-up', 1, '0 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-down', 1, '0 4 12'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-word', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-word-end', 1, '5 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-space', 1, '0 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-space-end', 1, '5 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;previous-word', 1, '16 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;previous-space', 1, '16 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-paragraph', 1, '0 5 12'), + ('emacs', 'history-top;cursor-down*4;end-of-line;previous-paragraph', 1, '0 2 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-middle', 1, '17 2 11'), + ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-top', 1, '17 0 9'), + ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-bottom', 1, '17 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-centre-vertical', 1, '17 3 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;jump-forward o', 1, '17 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;jump-backward e', 1, '11 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;jump-to-forward a', 1, '17 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;jump-to-backward e', 1, '12 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;goto-line 5', 1, '17 4 5'), + ('emacs', 'history-top;cursor-down*4;end-of-line;search-forward ba', 1, '6 5 10'), + ('emacs', 'history-top;cursor-down*4;end-of-line;search-backward ba', 1, '9 5 10'), + ('emacs', 'history-top;cursor-down*4;end-of-line;search-forward (b', 1, '17 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;search-backward to', 1, '5 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-down', 3, '0 5 11'), + ('emacs', 'history-top;cursor-down*4;end-of-line;cursor-right', 3, '2 5 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-word', 3, '12 5 11'), + ('emacs', 'history-top;cursor-down*4;end-of-line;previous-word', 3, '12 4 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;next-word-end', 3, '17 5 11'), + ('emacs', 'history-top;cursor-down*4;end-of-line;page-up', 3, '0 0 13'), + ('emacs', 'history-top;cursor-down*4;end-of-line;scroll-up', 3, '0 4 13'), + ('vi', 'cursor-up', 1, '25 4 0'), + ('vi', 'cursor-down', 1, '0 5 0'), + ('vi', 'cursor-left', 1, '25 4 0'), + ('vi', 'cursor-right', 1, '0 5 0'), + ('vi', 'start-of-line', 1, '0 5 0'), + ('vi', 'end-of-line', 1, '0 5 0'), + ('vi', 'back-to-indentation', 1, '0 5 0'), + ('vi', 'top-line', 1, '0 0 0'), + ('vi', 'middle-line', 1, '0 2 0'), + ('vi', 'bottom-line', 1, '0 5 0'), + ('vi', 'history-top', 1, '0 0 13'), + ('vi', 'history-bottom', 1, '0 5 0'), + ('vi', 'page-up', 1, '22 5 4'), + ('vi', 'page-down', 1, '0 5 0'), + ('vi', 'halfpage-up', 1, '1 5 3'), + ('vi', 'halfpage-down', 1, '0 5 0'), + ('vi', 'scroll-up', 1, '25 5 1'), + ('vi', 'scroll-down', 1, '0 5 0'), + ('vi', 'next-word', 1, '1 5 0'), + ('vi', 'next-word-end', 1, '0 5 0'), + ('vi', 'next-space', 1, '1 5 0'), + ('vi', 'next-space-end', 1, '0 5 0'), + ('vi', 'previous-word', 1, '23 4 0'), + ('vi', 'previous-space', 1, '23 4 0'), + ('vi', 'next-paragraph', 1, '0 5 0'), + ('vi', 'previous-paragraph', 1, '0 3 0'), + ('vi', 'scroll-middle', 1, '0 5 0'), + ('vi', 'scroll-top', 1, '0 5 0'), + ('vi', 'scroll-bottom', 1, '0 5 0'), + ('vi', 'cursor-centre-vertical', 1, '0 3 0'), + ('vi', 'jump-forward o', 1, '0 5 0'), + ('vi', 'jump-backward e', 1, '0 5 0'), + ('vi', 'jump-to-forward a', 1, '0 5 0'), + ('vi', 'jump-to-backward e', 1, '0 5 0'), + ('vi', 'goto-line 5', 1, '0 5 5'), + ('vi', 'search-forward ba', 1, '4 5 10'), + ('vi', 'search-backward ba', 1, '9 5 10'), + ('vi', 'search-forward (b', 1, '0 5 0'), + ('vi', 'search-backward to', 1, '5 4 13'), + ('vi', 'cursor-down', 3, '0 5 0'), + ('vi', 'cursor-right', 3, '0 5 0'), + ('vi', 'next-word', 3, '1 5 0'), + ('vi', 'previous-word', 3, '13 4 0'), + ('vi', 'next-word-end', 3, '0 5 0'), + ('vi', 'page-up', 3, '0 5 12'), + ('vi', 'scroll-up', 3, '1 5 3'), + ('vi', 'history-top;cursor-up', 1, '0 0 13'), + ('vi', 'history-top;cursor-down', 1, '0 1 13'), + ('vi', 'history-top;cursor-left', 1, '0 0 13'), + ('vi', 'history-top;cursor-right', 1, '1 0 13'), + ('vi', 'history-top;start-of-line', 1, '0 0 13'), + ('vi', 'history-top;end-of-line', 1, '23 0 13'), + ('vi', 'history-top;back-to-indentation', 1, '0 0 13'), + ('vi', 'history-top;top-line', 1, '0 0 13'), + ('vi', 'history-top;middle-line', 1, '0 2 13'), + ('vi', 'history-top;bottom-line', 1, '0 5 13'), + ('vi', 'history-top*2', 1, '0 0 13'), + ('vi', 'history-top;history-bottom', 1, '0 5 0'), + ('vi', 'history-top;page-up', 1, '0 0 13'), + ('vi', 'history-top;page-down', 1, '0 0 9'), + ('vi', 'history-top;halfpage-up', 1, '0 0 13'), + ('vi', 'history-top;halfpage-down', 1, '0 0 10'), + ('vi', 'history-top;scroll-up', 1, '0 0 13'), + ('vi', 'history-top;scroll-down', 1, '0 0 12'), + ('vi', 'history-top;next-word', 1, '6 0 13'), + ('vi', 'history-top;next-word-end', 1, '4 0 13'), + ('vi', 'history-top;next-space', 1, '6 0 13'), + ('vi', 'history-top;next-space-end', 1, '4 0 13'), + ('vi', 'history-top;previous-word', 1, '0 0 13'), + ('vi', 'history-top;previous-space', 1, '0 0 13'), + ('vi', 'history-top;next-paragraph', 1, '0 2 13'), + ('vi', 'history-top;previous-paragraph', 1, '0 0 13'), + ('vi', 'history-top;scroll-middle', 1, '0 0 13'), + ('vi', 'history-top;scroll-top', 1, '0 0 13'), + ('vi', 'history-top;scroll-bottom', 1, '0 0 13'), + ('vi', 'history-top;cursor-centre-vertical', 1, '0 3 13'), + ('vi', 'history-top;jump-forward o', 1, '0 0 13'), + ('vi', 'history-top;jump-backward e', 1, '0 0 13'), + ('vi', 'history-top;jump-to-forward a', 1, '3 0 13'), + ('vi', 'history-top;jump-to-backward e', 1, '0 0 13'), + ('vi', 'history-top;goto-line 5', 1, '0 0 5'), + ('vi', 'history-top;search-forward ba', 1, '4 5 10'), + ('vi', 'history-top;search-backward ba', 1, '9 5 10'), + ('vi', 'history-top;search-forward (b', 1, '0 0 13'), + ('vi', 'history-top;search-backward to', 1, '5 4 13'), + ('vi', 'history-top;cursor-down', 3, '0 3 13'), + ('vi', 'history-top;cursor-right', 3, '3 0 13'), + ('vi', 'history-top;next-word', 3, '11 0 13'), + ('vi', 'history-top;previous-word', 3, '0 0 13'), + ('vi', 'history-top;next-word-end', 3, '10 0 13'), + ('vi', 'history-top;page-up', 3, '0 0 13'), + ('vi', 'history-top;scroll-up', 3, '0 0 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-up', 1, '0 2 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 1, '3 4 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-left', 1, '2 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*4', 1, '4 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;start-of-line', 1, '0 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;end-of-line', 1, '27 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;back-to-indentation', 1, '0 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;top-line', 1, '0 0 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;middle-line', 1, '0 2 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;bottom-line', 1, '0 5 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;history-top', 1, '0 0 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;history-bottom', 1, '0 5 0'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;page-up', 1, '3 0 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;page-down', 1, '3 3 9'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;halfpage-up', 1, '3 0 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;halfpage-down', 1, '0 3 10'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 1, '3 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-down', 1, '3 3 12'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word', 1, '4 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 1, '8 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-space', 1, '11 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-space-end', 1, '9 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-word', 1, '0 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-space', 1, '0 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-paragraph', 1, '0 5 12'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-paragraph', 1, '0 2 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-middle', 1, '3 2 12'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-top', 1, '3 0 10'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-bottom', 1, '3 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-centre-vertical', 1, '3 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-forward o', 1, '11 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-backward e', 1, '1 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-to-forward a', 1, '4 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;jump-to-backward e', 1, '2 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;goto-line 5', 1, '3 3 5'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;search-forward ba', 1, '4 5 10'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;search-backward ba', 1, '9 5 10'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;search-forward (b', 1, '3 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;search-backward to', 1, '5 4 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;cursor-down', 3, '0 5 12'), + ('vi', 'history-top;cursor-down*3;cursor-right*4', 3, '6 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word', 3, '11 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;previous-word', 3, '20 1 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;next-word-end', 3, '19 3 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;page-up', 3, '3 0 13'), + ('vi', 'history-top;cursor-down*3;cursor-right*3;scroll-up', 3, '3 3 13'), + ('vi', 'page-up;cursor-up', 1, '12 4 4'), + ('vi', 'page-up;cursor-down', 1, '1 5 3'), + ('vi', 'page-up;cursor-left', 1, '21 5 4'), + ('vi', 'page-up;cursor-right', 1, '0 5 3'), + ('vi', 'page-up;start-of-line', 1, '0 5 4'), + ('vi', 'page-up;end-of-line', 1, '22 5 4'), + ('vi', 'page-up;back-to-indentation', 1, '0 5 4'), + ('vi', 'page-up;top-line', 1, '0 0 4'), + ('vi', 'page-up;middle-line', 1, '0 2 4'), + ('vi', 'page-up;bottom-line', 1, '0 5 4'), + ('vi', 'page-up;history-top', 1, '0 0 13'), + ('vi', 'page-up;history-bottom', 1, '0 5 0'), + ('vi', 'page-up*2', 1, '0 5 8'), + ('vi', 'page-up;page-down', 1, '0 5 0'), + ('vi', 'page-up;halfpage-up', 1, '23 5 7'), + ('vi', 'page-up;halfpage-down', 1, '25 5 1'), + ('vi', 'page-up;scroll-up', 1, '12 5 5'), + ('vi', 'page-up;scroll-down', 1, '1 5 3'), + ('vi', 'page-up;next-word', 1, '0 5 3'), + ('vi', 'page-up;next-word-end', 1, '0 5 3'), + ('vi', 'page-up;next-space', 1, '0 5 3'), + ('vi', 'page-up;next-space-end', 1, '0 5 3'), + ('vi', 'page-up;previous-word', 1, '12 5 4'), + ('vi', 'page-up;previous-space', 1, '12 5 4'), + ('vi', 'page-up;next-paragraph', 1, '0 5 2'), + ('vi', 'page-up;previous-paragraph', 1, '0 1 4'), + ('vi', 'page-up;scroll-middle', 1, '22 2 1'), + ('vi', 'page-up;scroll-top', 1, '22 5 4'), + ('vi', 'page-up;scroll-bottom', 1, '22 5 4'), + ('vi', 'page-up;cursor-centre-vertical', 1, '22 3 4'), + ('vi', 'page-up;jump-forward o', 1, '22 5 4'), + ('vi', 'page-up;jump-backward e', 1, '21 5 4'), + ('vi', 'page-up;jump-to-forward a', 1, '22 5 4'), + ('vi', 'page-up;jump-to-backward e', 1, '16 5 4'), + ('vi', 'page-up;goto-line 5', 1, '22 5 5'), + ('vi', 'page-up;search-forward ba', 1, '4 5 10'), + ('vi', 'page-up;search-backward ba', 1, '9 5 10'), + ('vi', 'page-up;search-forward (b', 1, '22 5 4'), + ('vi', 'page-up;search-backward to', 1, '5 4 13'), + ('vi', 'page-up;cursor-down', 3, '25 5 1'), + ('vi', 'page-up;cursor-right', 3, '0 5 2'), + ('vi', 'page-up;next-word', 3, '5 5 1'), + ('vi', 'page-up;previous-word', 3, '0 5 4'), + ('vi', 'page-up;next-word-end', 3, '8 5 1'), + ('vi', 'page-up*2', 3, '27 1 13'), + ('vi', 'page-up;scroll-up', 3, '23 5 7'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-up', 1, '11 3 4'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-down', 1, '22 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-left', 1, '11 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-right', 1, '0 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;start-of-line', 1, '0 4 4'), + ('vi', 'page-up;cursor-up;end-of-line*2', 1, '12 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;back-to-indentation', 1, '0 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;top-line', 1, '0 0 4'), + ('vi', 'page-up;cursor-up;end-of-line;middle-line', 1, '0 2 4'), + ('vi', 'page-up;cursor-up;end-of-line;bottom-line', 1, '0 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;history-top', 1, '0 0 13'), + ('vi', 'page-up;cursor-up;end-of-line;history-bottom', 1, '0 5 0'), + ('vi', 'page-up;cursor-up;end-of-line;page-up', 1, '12 4 8'), + ('vi', 'page-up;cursor-up;end-of-line;page-down', 1, '25 4 0'), + ('vi', 'page-up;cursor-up;end-of-line;halfpage-up', 1, '0 4 7'), + ('vi', 'page-up;cursor-up;end-of-line;halfpage-down', 1, '0 4 1'), + ('vi', 'page-up;cursor-up;end-of-line;scroll-up', 1, '11 4 5'), + ('vi', 'page-up;cursor-up;end-of-line;scroll-down', 1, '22 4 3'), + ('vi', 'page-up;cursor-up;end-of-line;next-word', 1, '0 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;next-word-end', 1, '4 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;next-space', 1, '0 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;next-space-end', 1, '4 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;previous-word', 1, '8 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;previous-space', 1, '8 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;next-paragraph', 1, '0 5 2'), + ('vi', 'page-up;cursor-up;end-of-line;previous-paragraph', 1, '0 1 4'), + ('vi', 'page-up;cursor-up;end-of-line;scroll-middle', 1, '12 2 2'), + ('vi', 'page-up;cursor-up;end-of-line;scroll-top', 1, '12 0 0'), + ('vi', 'page-up;cursor-up;end-of-line;scroll-bottom', 1, '12 5 5'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-centre-vertical', 1, '12 3 4'), + ('vi', 'page-up;cursor-up;end-of-line;jump-forward o', 1, '12 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;jump-backward e', 1, '12 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;jump-to-forward a', 1, '12 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;jump-to-backward e', 1, '12 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;goto-line 5', 1, '12 4 5'), + ('vi', 'page-up;cursor-up;end-of-line;search-forward ba', 1, '4 5 10'), + ('vi', 'page-up;cursor-up;end-of-line;search-backward ba', 1, '9 5 10'), + ('vi', 'page-up;cursor-up;end-of-line;search-forward (b', 1, '12 4 4'), + ('vi', 'page-up;cursor-up;end-of-line;search-backward to', 1, '5 4 13'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-down', 3, '0 5 2'), + ('vi', 'page-up;cursor-up;end-of-line;cursor-right', 3, '2 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;next-word', 3, '12 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;previous-word', 3, '10 3 4'), + ('vi', 'page-up;cursor-up;end-of-line;next-word-end', 3, '21 5 4'), + ('vi', 'page-up;cursor-up;end-of-line;page-up', 3, '23 0 13'), + ('vi', 'page-up;cursor-up;end-of-line;scroll-up', 3, '0 4 7'), + ('vi', 'history-top;cursor-down;cursor-right*11;cursor-up', 1, '11 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;cursor-down', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;cursor-left', 1, '10 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*12', 1, '12 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;start-of-line', 1, '0 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;end-of-line', 1, '27 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;back-to-indentation', 1, '2 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;top-line', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;middle-line', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;bottom-line', 1, '0 5 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;history-top', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;history-bottom', 1, '0 5 0'), + ('vi', 'history-top;cursor-down;cursor-right*11;page-up', 1, '11 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;page-down', 1, '5 1 9'), + ('vi', 'history-top;cursor-down;cursor-right*11;halfpage-up', 1, '11 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;halfpage-down', 1, '11 1 10'), + ('vi', 'history-top;cursor-down;cursor-right*11;scroll-up', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;scroll-down', 1, '0 1 12'), + ('vi', 'history-top;cursor-down;cursor-right*11;next-word', 1, '12 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;next-word-end', 1, '12 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;next-space', 1, '14 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;next-space-end', 1, '12 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;previous-word', 1, '2 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;previous-space', 1, '2 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;next-paragraph', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;previous-paragraph', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;scroll-middle', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;scroll-top', 1, '11 0 12'), + ('vi', 'history-top;cursor-down;cursor-right*11;scroll-bottom', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;cursor-centre-vertical', 1, '11 3 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;jump-forward o', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;jump-backward e', 1, '8 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;jump-to-forward a', 1, '23 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;jump-to-backward e', 1, '9 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;goto-line 5', 1, '11 1 5'), + ('vi', 'history-top;cursor-down;cursor-right*11;search-forward ba', 1, '4 5 10'), + ('vi', 'history-top;cursor-down;cursor-right*11;search-backward ba', 1, '9 5 10'), + ('vi', 'history-top;cursor-down;cursor-right*11;search-forward (b', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;search-backward to', 1, '5 4 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;next-matching-bracket', 1, '21 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*11;previous-matching-bracket', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;cursor-up', 1, '20 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;cursor-down', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;cursor-left', 1, '19 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*21', 1, '21 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;start-of-line', 1, '0 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;end-of-line', 1, '27 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;back-to-indentation', 1, '2 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;top-line', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;middle-line', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;bottom-line', 1, '0 5 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;history-top', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;history-bottom', 1, '0 5 0'), + ('vi', 'history-top;cursor-down;cursor-right*20;page-up', 1, '20 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;page-down', 1, '5 1 9'), + ('vi', 'history-top;cursor-down;cursor-right*20;halfpage-up', 1, '20 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;halfpage-down', 1, '17 1 10'), + ('vi', 'history-top;cursor-down;cursor-right*20;scroll-up', 1, '20 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;scroll-down', 1, '0 1 12'), + ('vi', 'history-top;cursor-down;cursor-right*20;next-word', 1, '23 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;next-word-end', 1, '21 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;next-space', 1, '23 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;next-space-end', 1, '21 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;previous-word', 1, '19 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;previous-space', 1, '18 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;next-paragraph', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;previous-paragraph', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;scroll-middle', 1, '20 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;scroll-top', 1, '20 0 12'), + ('vi', 'history-top;cursor-down;cursor-right*20;scroll-bottom', 1, '20 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;cursor-centre-vertical', 1, '20 3 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;jump-forward o', 1, '20 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;jump-backward e', 1, '8 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;jump-to-forward a', 1, '23 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;jump-to-backward e', 1, '9 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;goto-line 5', 1, '20 1 5'), + ('vi', 'history-top;cursor-down;cursor-right*20;search-forward ba', 1, '4 5 10'), + ('vi', 'history-top;cursor-down;cursor-right*20;search-backward ba', 1, '9 5 10'), + ('vi', 'history-top;cursor-down;cursor-right*20;search-forward (b', 1, '20 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;search-backward to', 1, '5 4 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;next-matching-bracket', 1, '18 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*20;previous-matching-bracket', 1, '18 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;cursor-up', 1, '13 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;cursor-down', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;cursor-left', 1, '12 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*14', 1, '14 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;start-of-line', 1, '0 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;end-of-line', 1, '27 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;back-to-indentation', 1, '2 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;top-line', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;middle-line', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;bottom-line', 1, '0 5 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;history-top', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;history-bottom', 1, '0 5 0'), + ('vi', 'history-top;cursor-down;cursor-right*13;page-up', 1, '13 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;page-down', 1, '5 1 9'), + ('vi', 'history-top;cursor-down;cursor-right*13;halfpage-up', 1, '13 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;halfpage-down', 1, '13 1 10'), + ('vi', 'history-top;cursor-down;cursor-right*13;scroll-up', 1, '13 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;scroll-down', 1, '0 1 12'), + ('vi', 'history-top;cursor-down;cursor-right*13;next-word', 1, '14 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;next-word-end', 1, '14 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;next-space', 1, '14 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;next-space-end', 1, '16 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;previous-word', 1, '12 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;previous-space', 1, '11 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;next-paragraph', 1, '0 2 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;previous-paragraph', 1, '0 0 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;scroll-middle', 1, '13 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;scroll-top', 1, '13 0 12'), + ('vi', 'history-top;cursor-down;cursor-right*13;scroll-bottom', 1, '13 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;cursor-centre-vertical', 1, '13 3 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;jump-forward o', 1, '13 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;jump-backward e', 1, '8 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;jump-to-forward a', 1, '23 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;jump-to-backward e', 1, '9 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;goto-line 5', 1, '13 1 5'), + ('vi', 'history-top;cursor-down;cursor-right*13;search-forward ba', 1, '4 5 10'), + ('vi', 'history-top;cursor-down;cursor-right*13;search-backward ba', 1, '9 5 10'), + ('vi', 'history-top;cursor-down;cursor-right*13;search-forward (b', 1, '13 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;search-backward to', 1, '5 4 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;next-matching-bracket', 1, '16 1 13'), + ('vi', 'history-top;cursor-down;cursor-right*13;previous-matching-bracket', 1, '13 1 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-up', 1, '0 3 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-down', 1, '0 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-left', 1, '16 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-right', 1, '0 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;start-of-line', 1, '0 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line*2', 1, '17 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;back-to-indentation', 1, '0 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;top-line', 1, '0 0 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;middle-line', 1, '0 2 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;bottom-line', 1, '0 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;history-top', 1, '0 0 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;history-bottom', 1, '0 5 0'), + ('vi', 'history-top;cursor-down*4;end-of-line;page-up', 1, '0 0 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;page-down', 1, '0 4 9'), + ('vi', 'history-top;cursor-down*4;end-of-line;halfpage-up', 1, '0 1 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;halfpage-down', 1, '0 4 10'), + ('vi', 'history-top;cursor-down*4;end-of-line;scroll-up', 1, '0 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;scroll-down', 1, '0 4 12'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-word', 1, '0 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-word-end', 1, '4 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-space', 1, '0 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-space-end', 1, '4 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;previous-word', 1, '16 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;previous-space', 1, '16 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-paragraph', 1, '0 5 12'), + ('vi', 'history-top;cursor-down*4;end-of-line;previous-paragraph', 1, '0 2 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;scroll-middle', 1, '17 2 11'), + ('vi', 'history-top;cursor-down*4;end-of-line;scroll-top', 1, '17 0 9'), + ('vi', 'history-top;cursor-down*4;end-of-line;scroll-bottom', 1, '17 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-centre-vertical', 1, '17 3 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;jump-forward o', 1, '17 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;jump-backward e', 1, '11 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;jump-to-forward a', 1, '17 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;jump-to-backward e', 1, '12 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;goto-line 5', 1, '17 4 5'), + ('vi', 'history-top;cursor-down*4;end-of-line;search-forward ba', 1, '4 5 10'), + ('vi', 'history-top;cursor-down*4;end-of-line;search-backward ba', 1, '9 5 10'), + ('vi', 'history-top;cursor-down*4;end-of-line;search-forward (b', 1, '17 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;search-backward to', 1, '5 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-down', 3, '0 5 11'), + ('vi', 'history-top;cursor-down*4;end-of-line;cursor-right', 3, '2 5 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-word', 3, '12 5 11'), + ('vi', 'history-top;cursor-down*4;end-of-line;previous-word', 3, '12 4 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;next-word-end', 3, '16 5 11'), + ('vi', 'history-top;cursor-down*4;end-of-line;page-up', 3, '0 0 13'), + ('vi', 'history-top;cursor-down*4;end-of-line;scroll-up', 3, '0 4 13'), +) +MPLEX_TMUX_SELECTIONS = ( + ('emacs', 'begin-selection;cursor-right*2;copy-selection-and-cancel', 'al'), + ('emacs', 'begin-selection;copy-selection-and-cancel', ''), + ('emacs', 'begin-selection;next-word;copy-selection-and-cancel', 'alpha '), + ('emacs', 'cursor-right*4;begin-selection;next-word-end;copy-selection-and-cancel', 'a'), + ('emacs', 'begin-selection;end-of-line;copy-selection-and-cancel', 'alpha beta-gamma delta'), + ('emacs', 'begin-selection;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n'), + ('emacs', 'cursor-right*4;begin-selection;cursor-down*2;copy-selection-and-cancel', 'a beta-gamma delta\n indented (a [b] {c}) tail\n'), + ('emacs', 'select-line;copy-selection-and-cancel', 'alpha beta-gamma delta'), + ('emacs', 'select-line;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail'), + ('emacs', 'cursor-right*5;select-word;copy-selection-and-cancel', 'alpha'), + ('emacs', 'select-word;copy-selection-and-cancel', 'alpha'), + ('emacs', 'cursor-right*4;copy-end-of-line-and-cancel', 'a beta-gamma delta'), + ('emacs', 'copy-line-and-cancel', 'alpha beta-gamma delta'), + ('emacs', 'cursor-down*2;copy-line-and-cancel', ''), + ('emacs', 'cursor-down*2;copy-end-of-line-and-cancel', ''), + ('emacs', 'cursor-right*2;begin-selection;rectangle-toggle;cursor-down;cursor-right*2;copy-selection-and-cancel', 'ph\nin'), + ('emacs', 'cursor-right*8;begin-selection;cursor-left*4;copy-selection-and-cancel', 'a be'), + ('emacs', 'cursor-down;cursor-right*5;begin-selection;cursor-up;copy-selection-and-cancel', ' beta-gamma delta\n ind'), + ('emacs', 'cursor-right*2;begin-selection;cursor-right*2;other-end;cursor-left;copy-selection-and-cancel', 'lph'), + ('emacs', 'begin-selection;next-word*3;copy-selection-and-cancel', 'alpha beta-'), + ('emacs', 'cursor-down*2;begin-selection;cursor-down*3;copy-selection-and-cancel', '\nkey=value; other_one, last.\npath/to/file.py x\n'), + ('emacs', 'page-up;begin-selection;page-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail\n\nkey=value; other_one, last.\n'), + ('vi', 'begin-selection;cursor-right*2;copy-selection-and-cancel', 'alp'), + ('vi', 'begin-selection;copy-selection-and-cancel', 'a'), + ('vi', 'begin-selection;next-word;copy-selection-and-cancel', 'alpha b'), + ('vi', 'cursor-right*4;begin-selection;next-word-end;copy-selection-and-cancel', 'a beta'), + ('vi', 'begin-selection;end-of-line;copy-selection-and-cancel', 'alpha beta-gamma delta\n'), + ('vi', 'begin-selection;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n '), + ('vi', 'cursor-right*4;begin-selection;cursor-down*2;copy-selection-and-cancel', 'a beta-gamma delta\n indented (a [b] {c}) tail\n\n'), + ('vi', 'select-line;copy-selection-and-cancel', 'alpha beta-gamma delta\n'), + ('vi', 'select-line;cursor-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail\n'), + ('vi', 'cursor-right*5;select-word;copy-selection-and-cancel', 'alpha'), + ('vi', 'select-word;copy-selection-and-cancel', 'alpha'), + ('vi', 'cursor-right*4;copy-end-of-line-and-cancel', 'a beta-gamma delta\n'), + ('vi', 'copy-line-and-cancel', 'alpha beta-gamma delta\n'), + ('vi', 'cursor-down*2;copy-line-and-cancel', '\n'), + ('vi', 'cursor-down*2;copy-end-of-line-and-cancel', '\n'), + ('vi', 'cursor-right*2;begin-selection;rectangle-toggle;cursor-down;cursor-right*2;copy-selection-and-cancel', 'pha\nind'), + ('vi', 'cursor-right*8;begin-selection;cursor-left*4;copy-selection-and-cancel', 'a bet'), + ('vi', 'cursor-down;cursor-right*5;begin-selection;cursor-up;copy-selection-and-cancel', ' beta-gamma delta\n inde'), + ('vi', 'cursor-right*2;begin-selection;cursor-right*2;other-end;cursor-left;copy-selection-and-cancel', 'lpha'), + ('vi', 'begin-selection;next-word*3;copy-selection-and-cancel', 'alpha beta-g'), + ('vi', 'cursor-down*2;begin-selection;cursor-down*3;copy-selection-and-cancel', '\nkey=value; other_one, last.\npath/to/file.py x\ns'), + ('vi', 'page-up;begin-selection;page-down;copy-selection-and-cancel', 'alpha beta-gamma delta\n indented (a [b] {c}) tail\n\nkey=value; other_one, last.\np'), +) +MPLEX_TMUX_CONTENT = ['alpha beta-gamma delta', ' indented (a [b] {c}) tail', '', 'key=value; other_one, last.', 'path/to/file.py x', 'short', '', ' spaced words here', 'foo(bar) baz[qux] {z}', 'end of block', '', 'one two three four five', 'a-b c-d e-f', 'tab\tword', 'UPPER lower Mixed_Case', 'x', '', 'last line of text here ok'] + + +class MplexCopyModeTests(unittest.TestCase): + def make(self, text, rows=5, cols=20, history=50, keys="emacs"): + pane = tai.MplexPane(1, cols, rows, history, tempfile.gettempdir()) + pane.feed(text.replace("\n", "\r\n").encode("utf-8")) + server = MplexStubServer() + server.options.set("mode-keys", keys) + mode = tai.MplexCopyMode(server, pane) + pane.push_mode(mode) + return server, pane, mode + + def run_cmd(self, mode, name, *words, count=1): + mode.pane.prefix_count = count + mode.command(name, list(words), None) + + def test_cursor_stands_between_characters_and_remembers_its_column(self): + server, pane, mode = self.make("alpha beta\nab\nlonger line here", rows=6, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "cursor-right", count=3) + self.run_cmd(mode, "cursor-down") + self.assertEqual((mode.cx, mode.abs_y()), (2, 1)) + self.run_cmd(mode, "cursor-down") + self.assertEqual((mode.cx, mode.abs_y()), (3, 2)) + self.run_cmd(mode, "end-of-line") + self.assertEqual(mode.cx, len("longer line here")) + self.run_cmd(mode, "cursor-right") + self.assertEqual((mode.cx, mode.abs_y()), (0, 3)) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "end-of-line") + self.run_cmd(mode, "cursor-right") + self.assertEqual((mode.cx, mode.abs_y()), (0, 1)) + self.run_cmd(mode, "cursor-left") + self.assertEqual((mode.cx, mode.abs_y()), (10, 0)) + + def test_search_cursor_placement_differs_between_emacs_and_vi(self): + for keys, first, again in (("emacs", (5, 0), (16, 0)), ("vi", (11, 0), (6, 1))): + server, pane, mode = self.make("alpha beta alpha\ngamma alpha", rows=6, cols=30, keys=keys) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "search-forward", "alpha") + self.assertEqual((mode.cx, mode.abs_y()), first, keys) + self.run_cmd(mode, "search-again") + self.assertEqual((mode.cx, mode.abs_y()), again, keys) + + def test_backward_search_lands_on_the_match_start_and_wraps(self): + server, pane, mode = self.make("alpha beta alpha\ngamma alpha", rows=6, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "search-backward", "alpha") + self.assertEqual((mode.cx, mode.abs_y()), (6, 1)) + self.run_cmd(mode, "search-again") + self.assertEqual((mode.cx, mode.abs_y()), (11, 0)) + + def test_search_string_survives_copy_mode_and_restarts_upward(self): + server, pane, mode = self.make("one two one two", rows=4, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "search-forward", "two") + mode.close() + fresh = tai.MplexCopyMode(server, pane) + pane.push_mode(fresh) + self.assertEqual(fresh.search[:2], ("two", False)) + + def test_smart_case_and_failed_search(self): + server, pane, mode = self.make("Apple\napple\nAPPLE", rows=4, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "search-forward", "APPLE") + self.assertEqual(mode.abs_y(), 2) + 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") + + def test_select_word_on_blanks_takes_the_previous_word(self): + server, pane, mode = self.make("alpha beta", rows=4, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "cursor-right", count=5) + self.run_cmd(mode, "select-word") + self.assertEqual(mode.selection_text(), "alpha") + + def test_selection_end_is_exclusive_for_emacs_and_inclusive_for_vi(self): + for keys, expect in (("emacs", "alp"), ("vi", "alph")): + server, pane, mode = self.make("alpha beta", rows=4, cols=30, keys=keys) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "begin-selection") + self.run_cmd(mode, "cursor-right", count=3) + self.assertEqual(mode.selection_text(), expect, keys) + + def test_copy_modes_clear_or_end_the_selection(self): + server, pane, mode = self.make("alpha beta\ngamma", rows=4, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "begin-selection") + self.run_cmd(mode, "next-word") + self.run_cmd(mode, "copy-selection") + self.assertEqual(server.copied[-1][0], "alpha ") + self.assertIsNone(mode.anchor) + self.assertIn(mode, pane.modes) + self.run_cmd(mode, "select-line") + self.run_cmd(mode, "copy-selection-and-cancel") + self.assertEqual(server.copied[-1][0], "alpha beta") + self.assertNotIn(mode, pane.modes) + + def test_pipe_command_travels_with_the_copy(self): + server, pane, mode = self.make("alpha beta", rows=4, cols=30) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "copy-pipe-line", "xclip -i") + self.assertEqual(server.copied[-1], ("alpha beta", "xclip -i", False)) + + def test_repeat_count_applies_once(self): + server, pane, mode = self.make("\n".join(str(n) for n in range(10)), rows=10) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "cursor-down", count=4) + self.assertEqual(mode.abs_y(), 4) + self.run_cmd(mode, "cursor-down") + self.assertEqual(mode.abs_y(), 5) + + def test_output_while_scrolled_keeps_the_view(self): + server, pane, mode = self.make("\n".join("row%d" % n for n in range(12)), rows=5) + self.run_cmd(mode, "page-up") + top_before = mode.text(mode.top()) + pane.feed(b"\r\nnew1\r\nnew2\r\n") + self.assertEqual(mode.text(mode.top()), top_before) + self.run_cmd(mode, "history-bottom") + self.assertEqual(mode.oy, 0) + + def test_render_marks_selection_and_position(self): + server, pane, mode = self.make("alpha\nbeta", rows=3, cols=12) + self.run_cmd(mode, "history-top") + self.run_cmd(mode, "begin-selection") + self.run_cmd(mode, "cursor-right", count=3) + grid, cursor = mode.render(None) + self.assertEqual([cell[2] for cell in grid[0][:5]], ["43", "43", "43", None, None]) + self.assertTrue("".join(cell[0] for cell in grid[0]).rstrip().endswith("[0/0]")) + self.assertEqual(cursor, (3, 0)) + + def test_view_mode_from_static_lines(self): + pane = tai.MplexPane(1, 20, 4, 10, tempfile.gettempdir()) + server = MplexStubServer() + mode = tai.MplexCopyMode(server, pane, lines=["l%d" % n for n in range(10)]) + self.assertEqual(mode.name, "view-mode") + self.assertEqual(mode.oy, mode.hist()) + self.assertEqual(mode.text(mode.top()), "l0") + self.run_cmd(mode, "history-bottom") + self.assertEqual(mode.text(mode.abs_y()), "l9") + + def test_unknown_command_is_an_error(self): + server, pane, mode = self.make("x") + with self.assertRaises(tai.MplexError): + self.run_cmd(mode, "no-such-thing") + + def test_key_dispatch_uses_mode_keys_table(self): + server, pane, mode = self.make("a\nb\nc", rows=3) + server.options.set("mode-keys", "vi") + self.run_cmd(mode, "history-top") + mode.key("j", b"j", None) + self.assertEqual(mode.abs_y(), 1) + server.options.set("mode-keys", "emacs") + mode.key("C-n", b"\x0e", None) + self.assertEqual(mode.abs_y(), 2) + mode.key("q", b"q", None) + self.assertNotIn(mode, pane.modes) + + +class MplexCopyModeVersusTmuxTests(unittest.TestCase): + """The same key sequences, run in the installed tmux 3.6 and recorded, must give the same cursor, + scroll position and paste buffer here (see MPLEX_TMUX_CASES).""" + + @staticmethod + def expand(commands): + out = [] + for part in commands.split(";"): + if "*" in part: + name, count = part.rsplit("*", 1) + out.extend([name] * int(count)) + else: + out.append(part) + return out + + def run_sequence(self, keys, commands, count=1): + pane = tai.MplexPane(1, 30, 6, 100, tempfile.gettempdir()) + pane.feed(("\r\n".join(MPLEX_TMUX_CONTENT) + "\r\n").encode()) + server = MplexStubServer() + server.options.set("mode-keys", keys) + mode = tai.MplexCopyMode(server, pane) + pane.push_mode(mode) + steps = self.expand(commands) + for index, step in enumerate(steps): + words = step.split() + pane.prefix_count = count if index == len(steps) - 1 and count > 1 else 1 + try: + mode.command(words[0], words[1:], None) + except tai.MplexError: + pass + if mode not in pane.modes: + break + return server, mode + + def test_cursor_and_scroll_results_match_tmux(self): + wrong = [] + for keys, commands, count, expected in MPLEX_TMUX_CASES: + _server, mode = self.run_sequence(keys, commands, count) + got = "%d %d %d" % (mode.cx, mode.cy, mode.oy) + if got != expected: + wrong.append("%s %s (x%d): tmux %s, here %s" % (keys, commands[-90:], count, expected, got)) + self.assertEqual(wrong, [], "%d of %d differ:\n%s" % (len(wrong), len(MPLEX_TMUX_CASES), "\n".join(wrong[:12]))) + + def test_selections_and_copies_match_tmux(self): + wrong = [] + for keys, commands, expected in MPLEX_TMUX_SELECTIONS: + server, _mode = self.run_sequence(keys, "history-top;" + commands) + got = server.copied[-1][0] if server.copied else "" + if got != expected: + wrong.append("%s %s: tmux %r, here %r" % (keys, commands[-80:], expected, got)) + self.assertEqual(wrong, [], "%d of %d differ:\n%s" % (len(wrong), len(MPLEX_TMUX_SELECTIONS), "\n".join(wrong[:8]))) + + def test_fixture_set_is_not_empty(self): + self.assertGreater(len(MPLEX_TMUX_CASES), 700) + self.assertGreater(len(MPLEX_TMUX_SELECTIONS), 40) + + +class MplexDefaultCopyBindingsTests(unittest.TestCase): + def test_every_default_copy_mode_binding_runs_without_crashing(self): + config = tai.MplexConfig() + ran = 0 + for table in ("copy-mode", "copy-mode-vi"): + for key, binding in config.tables[table].items(): + words = tai.MplexCommandParser.parse(binding.command)[0] + if words[0] == "command-prompt": + words = tai.MplexCommandParser.parse(words[-1].replace("%%", "3"))[0] + if words[:2] != ["send-keys", "-X"]: + continue + words = [item for item in words[2:] if item != "--"] + pane = tai.MplexPane(1, 30, 6, 100, tempfile.gettempdir()) + pane.feed(("\r\n".join("line %d (a b) {c} a,b,c" % n for n in range(40)) + "\r\n").encode()) + server = MplexStubServer() + mode = tai.MplexCopyMode(server, pane) + pane.push_mode(mode) + mode.command("page-up", [], None) + mode.command("begin-selection", [], None) + pane.prefix_count = 2 + try: + mode.command(words[0], words[1:], None) + except tai.MplexError as exc: + self.assertIn("Search failed", str(exc), "%s %s" % (table, key)) + ran += 1 + self.assertGreater(ran, 100) + + +class MplexSpecTests(unittest.TestCase): + """mplex.md is the specification; it must list exactly what the code has.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "mplex.md"), "r", encoding="utf-8") as handle: + cls.doc = handle.read() + + def section(self, heading): + start = self.doc.index("\n" + heading + "\n") + rest = self.doc[start + len(heading) + 2:] + end = re.search(r"\n#{1,3} ", rest) + return rest[:end.start()] if end else rest + + def first_column(self, text): + return re.findall(r"^\| `([^`]+)` \|", text, re.MULTILINE) + + def test_options_table_matches_the_code(self): + self.assertEqual(sorted(self.first_column(self.section("## Options"))), sorted(tai.MplexOptions.DEFS)) + + def test_commands_table_matches_the_code(self): + self.assertEqual(sorted(self.first_column(self.section("## Commands"))), sorted(tai.MPLEX_COMMAND_SPECS)) + + def test_unsupported_commands_are_all_named(self): + for name in tai.MPLEX_UNSUPPORTED: + if name != "choose-tree-mouse": + self.assertIn("`%s`" % name, self.doc) + + def test_every_default_binding_is_documented(self): + for table, heading in (("prefix", "### prefix table (after `C-b`)"), ("copy-mode", "### copy-mode table (`mode-keys emacs`)"), + ("copy-mode-vi", "### copy-mode-vi table (`mode-keys vi`)")): + section = self.section(heading) + documented = re.findall(r"^\| `(.+?)` \| `(.+?)` \| (yes)?", section, re.MULTILINE) + documented = [(key.replace("\\|", "|"), command.replace("\\|", "|"), bool(rep)) for key, command, rep in documented] + expected = [(key, command, repeat) for tbl, key, command, repeat, _note in tai.MPLEX_DEFAULT_KEYS if tbl == table] + self.assertEqual(documented, expected, table) + + def test_every_copy_mode_command_is_documented(self): + for name in dir(tai.MplexCopyMode): + if name.startswith("c_"): + self.assertIn("`%s`" % name[2:].replace("_", "-"), self.doc, name) + + 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"): + self.assertIn("`%s`" % name, self.doc, name) + self.assertTrue(hasattr(tai, name), name) + + def test_wire_kinds_are_documented(self): + for kind in ("H", "I", "R", "A", "O", "M", "E", "D", "X", "Z"): + self.assertRegex(self.doc, r"`%s`" % kind) + + def test_no_em_dashes(self): + self.assertNotIn("—", self.doc) + + +class MplexSessionTests(unittest.TestCase): + """Window selection history; the expectations were taken from tmux 3.6 (compare3: select, kill, swap).""" + + def make(self, count): + session = tai.MplexSession(0, "s", tai.MplexOptions(), "/", {}) + for index in range(count): + window = tai.MplexWindow(index, "w%d" % index, 80, 24, session.options) + window.session = session + session.windows[index] = window + session.current = 0 + return session + + def test_last_is_the_window_that_was_current_before(self): + session = self.make(3) + self.assertIsNone(session.last) + session.select(1) + self.assertEqual(session.last, 0) + session.select(2) + self.assertEqual((session.current, session.last), (2, 1)) + session.select(1) + self.assertEqual((session.current, session.last), (1, 2)) + + def test_history_keeps_older_windows_behind_the_last_one(self): + session = self.make(3) + session.select(1) + session.select(2) + session.history = [w for w in session.history if w is not session.windows[2]] + self.assertEqual([w.name for w in session.history], ["w1", "w0"]) + + def test_free_index_fills_gaps_from_the_base(self): + session = self.make(3) + del session.windows[1] + self.assertEqual(session.free_index(0), 1) + self.assertEqual(session.free_index(5), 5) + + def test_server_picks_last_then_previous_then_wraps_when_the_current_window_dies(self): + server = tai.MplexServer.__new__(tai.MplexServer) + server.windows = {} + server.panes = {} + server.marked = None + server.options = tai.MplexOptions() + server.clients = [] + server.sessions = {} + for scenario, current, selects, kill, expect in ( + ("last wins", 0, (1, 2), 2, 1), + ("wraps to the highest when nothing is below", 0, (), 0, 2), + ("previous below the killed index when there is no history", 1, (), 1, 0), + ): + session = self.make(3) + session.current = current + server.sessions = {0: session} + server.windows = {w.id: w for w in session.windows.values()} + for index in selects: + session.select(index) + tai.MplexServer.kill_window(server, session.windows[kill]) + self.assertEqual(session.current, expect, scenario) + + +class MplexPromptTests(unittest.TestCase): + class Client: + def __init__(self): + self.prompt = None + self.redispatched = [] + + def redispatch(self, name, raw): + self.redispatched.append(name) + + def make(self, text="", **kwargs): + done = [] + client = self.Client() + history = [] + prompt = tai.MplexPrompt(":", text, done.append, history, **kwargs) + client.prompt = prompt + server = MplexStubServer() + server.top_buffer_text = lambda: "PASTE" + return prompt, client, server, done, history + + def type(self, prompt, client, server, *names): + for name in names: + raw = name.encode() if len(name) == 1 else b"" + prompt.key(server, client, "Space" if name == " " else name, b" " if name == " " else raw) + + def test_editing_keys(self): + prompt, client, server, done, history = self.make("hello world") + self.type(prompt, client, server, "C-a", "C-k") + self.assertEqual(prompt.text, "") + self.type(prompt, client, server, "a", "b", "c", "Left", "X") + self.assertEqual((prompt.text, prompt.pos), ("abXc", 3)) + self.type(prompt, client, server, "BSpace", "C-e", "C-h") + self.assertEqual(prompt.text, "ab") + self.type(prompt, client, server, " ", "w", "o", "r", "d", "C-w") + self.assertEqual(prompt.text, "ab ") + self.type(prompt, client, server, "C-u") + self.assertEqual(prompt.text, "") + self.type(prompt, client, server, "C-y") + self.assertEqual(prompt.text, "PASTE") + + def test_enter_runs_and_records_history(self): + prompt, client, server, done, history = self.make("go") + self.type(prompt, client, server, "Enter") + self.assertEqual(done, ["go"]) + self.assertIsNone(client.prompt) + self.assertEqual(history, ["go"]) + + def test_history_navigation(self): + prompt, client, server, done, history = self.make("") + history.extend(["one", "two"]) + self.type(prompt, client, server, "Up", "Up") + self.assertEqual(prompt.text, "one") + self.type(prompt, client, server, "Down", "Down") + self.assertEqual(prompt.text, "") + + def test_escape_cancels_and_calls_hook(self): + cancelled = [] + prompt, client, server, done, history = self.make("x", on_cancel=lambda: cancelled.append(1)) + self.type(prompt, client, server, "Escape") + self.assertEqual((done, cancelled, client.prompt), ([], [1], None)) + + def test_single_key_prompt(self): + prompt, client, server, done, history = self.make("", single=True) + self.type(prompt, client, server, "z") + self.assertEqual(done, ["z"]) + self.assertEqual(history, []) + + def test_confirm_prompt_only_accepts_y(self): + prompt, client, server, done, history = self.make("", confirm=True) + self.type(prompt, client, server, "n") + self.assertEqual((done, client.prompt), ([], None)) + prompt, client, server, done, history = self.make("", confirm=True) + self.type(prompt, client, server, "y") + self.assertEqual(done, ["y"]) + + def test_numeric_prompt_hands_back_the_next_key(self): + prompt, client, server, done, history = self.make("1", numeric=True) + self.type(prompt, client, server, "2", "j") + self.assertEqual(done, ["12"]) + self.assertEqual(client.redispatched, ["j"]) + + def test_key_name_prompt(self): + prompt, client, server, done, history = self.make("", key_name=True) + prompt.key(server, client, "C-x", b"\x18") + self.assertEqual(done, ["C-x"]) + + def test_incremental_hooks(self): + seen = [] + steps = [] + prompt, client, server, done, history = self.make("", on_change=seen.append, on_step=lambda name, text: steps.append((name, text))) + self.type(prompt, client, server, "a", "b", "C-s", "C-r") + self.assertEqual(seen, ["a", "ab"]) + self.assertEqual(steps, [("C-s", "ab"), ("C-r", "ab")]) + + def test_history_is_capped(self): + prompt, client, server, done, history = self.make("x") + history.extend(str(n) for n in range(tai.MPLEX_PROMPT_HISTORY_CAP + 20)) + self.type(prompt, client, server, "Enter") + self.assertEqual(len(history), tai.MPLEX_PROMPT_HISTORY_CAP) + + +class MplexCommandTemplateTests(unittest.TestCase): + def setUp(self): + self.commands = tai.MplexCommands(None) + + def test_percent_substitution(self): + sub = self.commands.substitute + self.assertEqual(sub("rename-window %%", ["x"]), "rename-window x") + self.assertEqual(sub("a %1 b %2", ["one", "two"]), "a one b two") + self.assertEqual(sub("a %3", ["one"]), "a ") + + def test_quotes_inside_quoted_placeholder_are_escaped(self): + sub = self.commands.substitute + self.assertEqual(sub('rename-window "%%"', ['a"b$c\\d']), 'rename-window "a\\"b\\$c\\\\d"') + parsed = tai.MplexCommandParser.parse(sub('rename-window "%%"', ['a"b; kill-server'])) + self.assertEqual(parsed, [["rename-window", 'a"b; kill-server']]) + + def test_command_resolution(self): + r = tai.MplexCommands.resolve + self.assertEqual(r("neww"), "new-window") + self.assertEqual(r("splitw"), "split-window") + self.assertEqual(r("ls"), "list-sessions") + self.assertEqual(r("kill-ser"), "kill-server") + for bad, needle in (("kill", "ambiguous"), ("nonsense", "unknown command"), ("display-popup", "not supported")): + with self.assertRaises(tai.MplexError) as caught: + r(bad) + self.assertIn(needle, str(caught.exception)) + + def test_every_spec_has_a_handler(self): + for name in tai.MPLEX_COMMAND_SPECS: + self.assertTrue(hasattr(tai.MplexCommands, "cmd_" + name.replace("-", "_")), name) + + def test_unsupported_commands_have_no_spec(self): + for name in tai.MPLEX_UNSUPPORTED: + self.assertNotIn(name, tai.MPLEX_COMMAND_SPECS) + + +class MplexFramingTests(unittest.TestCase): + def test_frames_reassemble_and_reject_oversize(self): + client = tai.MplexClientConn(None, None, 1) + frame = b"I" + (3).to_bytes(4, "big") + b"abc" + self.assertEqual(client.frames(frame[:4]), []) + self.assertEqual(client.frames(frame[4:] + frame), [(b"I", b"abc"), (b"I", b"abc")]) + self.assertIsNone(client.frames(b"I" + (tai.MPLEX_FRAME_MAX + 1).to_bytes(4, "big"))) + + def test_send_is_capped(self): + client = tai.MplexClientConn(None, None, 1) + client.out = bytearray(tai.MPLEX_CLIENT_OUT_MAX + 1) + client.prev = [1] + self.assertFalse(client.send(b"O", b"x")) + self.assertIsNone(client.prev) + + +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): + controller = tai.PlexerControllerFactory.detect() + self.assertIsInstance(controller, tai.NativePlexerController) + self.assertEqual(controller.pane, "7") + + if __name__ == "__main__": unittest.main() diff --git a/tests/bashrc.py b/tests/bashrc.py index 3ba691e..d2001d9 100644 --- a/tests/bashrc.py +++ b/tests/bashrc.py @@ -43,6 +43,20 @@ async def _drain_forever(proc): pass +def _kill_mplex_server(tmp_home): + """The wrapped shell lives in a detached mplex server that outlives the test's client + process; stop it, or it keeps its sockets open inside the directory being deleted.""" + import subprocess + env = dict(os.environ, TAI_HOME=os.path.join(tmp_home, ".tai")) + for name in (tai.PLEXER_ACTIVE_ENV, tai.PLEXER_SOCKET_ENV, tai.PLEXER_PANE_ENV, "TMUX"): + env.pop(name, None) + try: + subprocess.run([sys.executable, str(Path(tai.__file__).resolve()), "--mplex", "kill-server"], env=env, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15) + except (OSError, subprocess.SubprocessError): + pass + + class _BashrcHookTestBase(TestCase): """Shared fixture for every test that installs the real plexer hook into a real .bashrc: a throwaway $HOME, the real tai.py binary copied into @@ -94,6 +108,7 @@ class _BashrcHookTestBase(TestCase): if self._drain_task is not None: self._drain_task.cancel() if self.tmp_home is not None: + _kill_mplex_server(self.tmp_home) shutil.rmtree(self.tmp_home, ignore_errors=True) @@ -151,6 +166,7 @@ class _ShellPlexerHookTestBase(TestCase): if self._drain_task is not None: self._drain_task.cancel() if self.tmp_home is not None: + _kill_mplex_server(self.tmp_home) shutil.rmtree(self.tmp_home, ignore_errors=True) diff --git a/tests/mplex.py b/tests/mplex.py new file mode 100644 index 0000000..0bef95c --- /dev/null +++ b/tests/mplex.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +"""tests/mplex.py - integration tests for the mplex terminal multiplexer (tai.py --mplex). + +Deterministic throughout, same reasoning as tests/plexer.py: no model decision exists +anywhere in this path, so the "real LLM" rule in tests/README.md has nothing to apply +to. What is real instead is everything else: the real tai.py binary as a client under +a real pty, a real detached server process, real bash panes, and the user's actual +key bytes (prefix, arrows, Alt-digits) written to the outer pty exactly as a terminal +would deliver them. State is verified two ways: what the attached terminal shows +(the client's output is fed through TuiVtScreen), and what `tai.py --mplex ` +reports about the server, which is the same command layer the key bindings call. + +Every test runs in a throwaway TAI_HOME, so a real ~/.tai/mplex.toml never leaks in +and a real session is never touched. teardown() always kills the server it started. +""" +import asyncio +import fcntl +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import termios +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from tai_test import TestCase, TestAssertionFailed, main_cli # noqa: E402 +import tai # noqa: E402 + +PREFIX = b"\x02" +UP = b"\x1b[A" +DOWN = b"\x1b[B" +RIGHT = b"\x1b[C" +LEFT = b"\x1b[D" +CTRL_UP = b"\x1b[1;5A" + + +def alt(char): + return b"\x1b" + char.encode() + + +class MplexTestBase(TestCase): + """Throwaway TAI_HOME, an attachable client with a decoded screen, and a CLI helper.""" + + mode = "pty" + timeout = 90.0 + rows = 24 + cols = 80 + config = None + + async def setup(self): + self.tmp_home = tempfile.mkdtemp(prefix="tai-mplex-test-") + self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": "", "TERM": "xterm-256color", "SHELL": "/bin/bash"} + self.env_overrides.pop("TAI_PLEXER_ACTIVE", None) + if self.config is not None: + with open(os.path.join(self.tmp_home, "mplex.toml"), "w", encoding="utf-8") as handle: + handle.write(self.config) + self.clients = [] + + def cli_env(self): + env = dict(os.environ) + env.update(self.env_overrides) + for name in (tai.PLEXER_ACTIVE_ENV, tai.PLEXER_SOCKET_ENV, tai.PLEXER_PANE_ENV, "TMUX"): + env.pop(name, None) + return env + + def cli(self, *words, check=True): + done = subprocess.run([sys.executable, str(self.tai_path), "--mplex"] + list(words), env=self.cli_env(), + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30) + if check and done.returncode != 0: + raise TestAssertionFailed("mplex %s failed (%d): %s" % (" ".join(words), done.returncode, (done.stdout + done.stderr).strip())) + return (done.stdout + done.stderr).strip() if not check else done.stdout.strip() + + async def attach(self, *words): + """Spawn a client; returns (proc, screen) with a drain task decoding its output.""" + proc = await self.spawn(extra_args=["--mplex"] + list(words), env_overrides={}) + fcntl.ioctl(proc._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.rows, self.cols, 0, 0)) + screen = tai.TuiVtScreen(self.rows, self.cols) + task = asyncio.ensure_future(self.drain(proc, screen)) + self.clients.append((proc, screen, task)) + return proc, screen + + @staticmethod + async def drain(proc, screen): + try: + while True: + chunk = await proc._read_chunk(65536) + if not chunk: + await asyncio.sleep(0.05) + else: + screen.feed(chunk) + except asyncio.CancelledError: + pass + + @staticmethod + def status(screen): + return screen.text().split("\n")[-1] + + async def wait_for(self, what, predicate, timeout=10.0): + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + try: + last = predicate() + except TestAssertionFailed: + last = None + if last: + return last + await asyncio.sleep(0.05) + raise TestAssertionFailed("timed out waiting for: %s" % what) + + async def type_keys(self, proc, data, settle=0.15): + os.write(proc._master_fd, data) + await asyncio.sleep(settle) + + async def teardown(self): + for _proc, _screen, task in self.clients: + task.cancel() + try: + self.cli("kill-server", check=False) + except Exception: + pass + await asyncio.sleep(0.3) + shutil.rmtree(self.tmp_home, ignore_errors=True) + + def expect(self, label, actual, expected): + if actual != expected: + raise TestAssertionFailed("%s: expected %r, got %r" % (label, expected, actual)) + + +class MplexLifecycleTest(MplexTestBase): + """Start, work, detach, survive, reattach, exit, server gone.""" + + name = "mplex-lifecycle" + + async def run(self): + proc, screen = await self.attach() + await self.wait_for("status bar with a window list", lambda: "0:bash*" in self.status(screen)) + sock = os.path.join(self.tmp_home, "plexer", tai.MPLEX_SOCKET_NAME) + await self.wait_for("server socket", lambda: os.path.exists(sock)) + os.write(proc._master_fd, b"echo keepalive-$((20+22))\r") + await self.wait_for("command output in the pane", lambda: "keepalive-42" in screen.text()) + listing_attached = self.cli("list-sessions") + + await self.type_keys(proc, PREFIX + b"d") + code = await proc.wait(timeout=10.0) + detached_text = screen.text() + server_alive = os.path.exists(sock) + listing_detached = self.cli("list-sessions") + + proc2, screen2 = await self.attach("attach") + await self.wait_for("the reattached screen", lambda: "keepalive-42" in screen2.text()) + await self.type_keys(proc2, b"exit\r") + code2 = await proc2.wait(timeout=10.0) + await self.wait_for("server to exit after its last pane", lambda: not os.path.exists(sock), timeout=8.0) + return {"code": code, "detached_text": detached_text, "server_alive": server_alive, "attached": listing_attached, + "detached": listing_detached, "code2": code2, "exit_text": screen2.text()} + + async def verify(self, result): + self.expect("detach exit code", result["code"], 0) + if "[detached (from session 0)]" not in result["detached_text"]: + raise TestAssertionFailed("detach message missing: %r" % result["detached_text"][-200:]) + if not result["server_alive"]: + raise TestAssertionFailed("server died when the only client detached") + if "(attached)" not in result["attached"] or "(attached)" in result["detached"]: + raise TestAssertionFailed("attached flag wrong: %r / %r" % (result["attached"], result["detached"])) + self.expect("exit code after last pane", result["code2"], 0) + if "[exited]" not in result["exit_text"]: + raise TestAssertionFailed("[exited] message missing: %r" % result["exit_text"][-200:]) + + +class MplexShortcutsTest(MplexTestBase): + """Every default prefix shortcut that has a keyboard effect, checked against the command layer.""" + + name = "mplex-shortcuts" + timeout = 150.0 + + def q(self, fmt, target=None): + words = ["display-message", "-p"] + (["-t", target] if target else []) + [fmt] + return self.cli(*words) + + async def run(self): + proc, screen = await self.attach() + await self.wait_for("first prompt", lambda: "0:bash*" in self.status(screen)) + failures = [] + + async def press(keys, settle=0.25): + await self.type_keys(proc, keys, settle) + + def check(label, actual, expected): + if actual != expected: + failures.append("%s: expected %r, got %r" % (label, expected, actual)) + + windows = lambda: self.cli("list-windows", "-F", "#{window_index}#{window_flags}").replace("\n", " ") + panes = lambda: self.cli("list-panes", "-F", "#{pane_index}:#{pane_width}x#{pane_height}").replace("\n", " ") + + await press(PREFIX + b"c") + await press(PREFIX + b"c") + check("c twice", windows(), "0 1- 2*") + await press(PREFIX + b"p") + check("p previous", windows(), "0 1* 2-") + await press(PREFIX + b"n") + check("n next", windows(), "0 1- 2*") + await press(PREFIX + b"l") + check("l last", windows(), "0 1* 2-") + await press(PREFIX + b"0") + check("0 select", windows(), "0* 1- 2") + await press(PREFIX + alt("n")) + check("M-n next -a (no alerts, no change)", windows(), "0* 1- 2") + await press(PREFIX + b"&") + await press(b"y", 0.4) + check("& confirm kill-window", len(windows().split()), 2) + + await press(PREFIX + b"'") + await press(b"2\r", 0.4) + check("' prompt select-window", self.q("#{window_index}"), "2") + await press(PREFIX + b",") + await press(b"\x15renamed\r", 0.4) + check(", rename-window", self.q("#{window_name}"), "renamed") + await press(PREFIX + b"$") + await press(b"\x15sess\r", 0.4) + check("$ rename-session", self.q("#{session_name}"), "sess") + await press(PREFIX + b".") + await press(b"9\r", 0.4) + check(". move-window", self.q("#{window_index}"), "9") + + await press(PREFIX + b"%") + check("% split-window -h", panes(), "0:40x23 1:39x23") + await press(PREFIX + b"\"") + check('" split-window', len(panes().split()), 3) + await press(PREFIX + b";") + check("; last-pane", self.q("#{pane_index}"), "1") + await press(PREFIX + b"o") + check("o next pane", self.q("#{pane_index}"), "2") + await press(PREFIX + UP) + check("Up select-pane -U", self.q("#{pane_index}"), "1") + await press(PREFIX + LEFT) + check("Left select-pane -L", self.q("#{pane_index}"), "0") + await press(PREFIX + RIGHT) + check("Right select-pane -R", self.q("#{pane_index}"), "1") + await press(PREFIX + DOWN) + check("Down select-pane -D", self.q("#{pane_index}"), "2") + before = self.q("#{pane_id}") + await press(PREFIX + b"{") + order = self.cli("list-panes", "-F", "#{pane_id}").split() + check("{ swap-pane -U moves the pane up", order.index(before), 1) + await press(PREFIX + b"}") + order = self.cli("list-panes", "-F", "#{pane_id}").split() + check("} swap-pane -D moves it back", order.index(before), 2) + first = self.cli("list-panes", "-F", "#{pane_id}").split() + await press(PREFIX + b"\x0f") + rotated = self.cli("list-panes", "-F", "#{pane_id}").split() + check("C-o rotate-window", rotated, first[1:] + first[:1]) + await press(PREFIX + alt("o")) + check("M-o rotate-window -D", self.cli("list-panes", "-F", "#{pane_id}").split(), first) + + for key, name in (("1", "even-horizontal"), ("2", "even-vertical"), ("3", "main-horizontal"), ("4", "main-vertical"), ("5", "tiled")): + await press(PREFIX + alt(key)) + sizes = self.cli("list-panes", "-F", "#{pane_width}x#{pane_height}").split() + if name == "even-horizontal": + check("M-1 " + name, sizes, ["26x23", "26x23", "26x23"]) + elif name == "even-vertical": + check("M-2 " + name, sizes, ["80x7", "80x7", "80x7"]) + elif name == "tiled": + check("M-5 " + name, sizes, ["39x11", "40x11", "80x11"]) + await press(PREFIX + b" ") + check("Space next-layout", len(self.cli("list-panes").splitlines()), 3) + + await press(PREFIX + b"x") + await press(b"n", 0.3) + check("x then n keeps the pane", len(self.cli("list-panes").splitlines()), 3) + count_before = len(self.cli("list-panes").splitlines()) + await press(PREFIX + b"x") + await press(b"y", 0.5) + check("x then y kill-pane", len(self.cli("list-panes").splitlines()), count_before - 1) + + await press(PREFIX + b"\"") + check("window back to 3 panes", len(self.cli("list-panes").splitlines()), 3) + await press(PREFIX + b"z") + check("z zoom on", self.q("#{window_zoomed_flag}"), "1") + check("zoomed status flag", "Z" in self.status(screen), True) + await press(PREFIX + b"z") + check("z zoom off", self.q("#{window_zoomed_flag}"), "0") + + await press(PREFIX + alt("5")) + self.cli("select-pane", "-t", ":.0") + check("tiled pane 0 height", self.q("#{pane_height}"), "11") + await press(PREFIX + b"\x1b[1;5B" + b"\x1b[1;5B") + check("C-Down twice (repeat) grows by 2", self.q("#{pane_height}"), "13") + await asyncio.sleep(0.8) + await press(b"\x1b[1;5B") + check("repeat window expired: C-Down no longer resizes", self.q("#{pane_height}"), "13") + check("pane 0 width before M-Right (tiled gives the odd cell to the last column)", self.q("#{pane_width}"), "39") + await press(PREFIX + b"\x1b[1;3C") + check("M-Right resize-pane -R 5", self.q("#{pane_width}"), "44") + + await press(PREFIX + b"!") + check("! break-pane", len(windows().split()), 3) + await press(PREFIX + b"m") + check("m mark pane", self.q("#{pane_marked}"), "1") + await press(PREFIX + b"M") + check("M clear mark", self.q("#{pane_marked}"), "0") + + await press(PREFIX + b"i") + check("i display-message", ", current pane" in self.status(screen), True) + await press(PREFIX + b"~") + check("~ show-messages opens a view", self.q("#{pane_mode}"), "view-mode") + await press(b"q", 0.3) + check("q leaves the view", self.q("#{pane_in_mode}"), "0") + await press(PREFIX + b"?") + check("? list-keys -N opens a view", self.q("#{pane_mode}"), "view-mode") + check("? lists the key notes", "C-b Space" in screen.text(), True) + await press(b"q", 0.3) + + await press(PREFIX + b"t") + check("t clock-mode", self.q("#{pane_mode}"), "clock-mode") + await press(b"q", 0.3) + check("any key leaves clock-mode", self.q("#{pane_in_mode}"), "0") + + await press(PREFIX + b"q") + await press(b"0", 0.4) + check("q display-panes digit selects", self.q("#{pane_index}"), "0") + + await press(PREFIX + b"w") + check("w choose-tree", self.q("#{pane_mode}"), "tree-mode") + await press(b"q", 0.3) + await press(PREFIX + b"s") + check("s choose-tree -s", self.q("#{pane_mode}"), "tree-mode") + await press(b"q", 0.3) + await press(PREFIX + b"D") + check("D choose-client", self.q("#{pane_mode}"), "client-mode") + await press(b"q", 0.3) + await press(PREFIX + b"f") + await press(b"renamed\r", 0.5) + check("f find-window selects the match", self.q("#{window_name}"), "renamed") + await press(PREFIX + b":") + await press(b"new-session -d -s second\r", 0.6) + check(": command prompt runs a command", "second" in self.cli("list-sessions"), True) + await press(PREFIX + b")") + check(") next session", self.q("#{session_name}"), "second") + await press(PREFIX + b"(") + check("( previous session", self.q("#{session_name}"), "sess") + await press(PREFIX + b"L") + check("L last session", self.q("#{session_name}"), "second") + return failures + + async def verify(self, result): + if result: + raise TestAssertionFailed("%d shortcut check(s) failed:\n %s" % (len(result), "\n ".join(result))) + + +class MplexCopyModeTest(MplexTestBase): + """Scrollback, search, selection, copy into a buffer, paste back into the shell.""" + + name = "mplex-copy-mode" + timeout = 60.0 + + async def run(self): + proc, screen = await self.attach() + await self.wait_for("first prompt", lambda: "0:bash*" in self.status(screen)) + os.write(proc._master_fd, b"seq 1 200; echo needle-in-haystack; seq 300 340\r") + await self.wait_for("output", lambda: "340" in screen.text()) + await self.type_keys(proc, PREFIX + b"[", 0.3) + mode = self.cli("display-message", "-p", "#{pane_mode}") + await self.type_keys(proc, b"\x1b[5~", 0.3) + pos_after_pgup = self.cli("display-message", "-p", "#{scroll_position}") + await self.type_keys(proc, b"\x1b<", 0.3) + top = self.cli("display-message", "-p", "#{scroll_position}") + history = self.cli("display-message", "-p", "#{history_size}") + await self.type_keys(proc, b"\x1b>", 0.3) + bottom = self.cli("display-message", "-p", "#{scroll_position}") + await self.type_keys(proc, b"\x12", 0.3) + os.write(proc._master_fd, b"needle\r") + await asyncio.sleep(0.5) + found = "needle-in-haystack" in screen.text() + await self.type_keys(proc, b"\x01", 0.2) + await self.type_keys(proc, b"\x00", 0.2) + await self.type_keys(proc, b"\x05", 0.2) + await self.type_keys(proc, b"\x1bw", 0.4) + buffer_text = self.cli("show-buffer") + mode_after = self.cli("display-message", "-p", "#{pane_in_mode}") + os.write(proc._master_fd, b"echo pasted:") + await asyncio.sleep(0.2) + await self.type_keys(proc, PREFIX + b"]", 0.4) + os.write(proc._master_fd, b"\r") + await asyncio.sleep(0.5) + captured = self.cli("capture-pane", "-p") + return {"mode": mode, "pgup": pos_after_pgup, "top": top, "history": history, "bottom": bottom, "found": found, + "buffer": buffer_text, "mode_after": mode_after, "captured": captured} + + async def verify(self, result): + self.expect("prefix [ enters copy-mode", result["mode"], "copy-mode") + if int(result["pgup"]) <= 0: + raise TestAssertionFailed("PageUp did not scroll back: %r" % result["pgup"]) + self.expect("M-< goes to the top of history", result["top"], result["history"]) + self.expect("M-> returns to the bottom", result["bottom"], "0") + if not result["found"]: + raise TestAssertionFailed("C-r incremental search did not bring the match on screen") + if "needle-in-haystack" not in result["buffer"]: + raise TestAssertionFailed("C-Space ... M-w did not copy the line: %r" % result["buffer"]) + self.expect("M-w leaves copy-mode", result["mode_after"], "0") + if "pasted:" not in result["captured"] or result["captured"].count("needle-in-haystack") < 2: + raise TestAssertionFailed("paste-buffer did not insert the copied text: %r" % result["captured"][-400:]) + + +class MplexConfigTest(MplexTestBase): + """~/.tai/mplex.toml: another prefix, new and removed bindings, options, vi copy keys, loud errors.""" + + name = "mplex-config" + config = """ +[options] +prefix = "C-a" +prefix2 = "F12" +base-index = 1 +status-left = "<#S> " +mode-keys = "vi" +status-position = "top" +no-such-option = 1 + +[keys.prefix] +"|" = "split-window -h" +"-" = "split-window -v" +"C-b" = false +c = { command = "new-window -n custom", note = "custom window" } +Q = "kill-server" +broken = "no-such-command" + +[keys.root] +"M-Right" = "next-window" +""" + timeout = 60.0 + + async def run(self): + proc, screen = await self.attach() + await self.wait_for("the config problem notice", lambda: "problem(s) skipped" in screen.text()) + notice_seen = True + await self.wait_for("first prompt", lambda: "1:bash*" in screen.text(), timeout=10.0) + top_line = screen.text().split("\n")[0] + await self.type_keys(proc, b"\x01c", 0.4) + names = self.cli("list-windows", "-F", "#{window_index}:#{window_name}").replace("\n", " ") + await self.type_keys(proc, b"\x01|", 0.4) + panes_after_pipe = len(self.cli("list-panes").splitlines()) + await self.type_keys(proc, b"\x02c", 0.4) + windows_after_old_prefix = len(self.cli("list-windows").splitlines()) + await self.type_keys(proc, b"\x1b[24~c", 0.4) + windows_after_prefix2 = len(self.cli("list-windows").splitlines()) + await self.type_keys(proc, b"\x1b[1;3C", 0.4) + current_after_root = self.cli("display-message", "-p", "#{window_index}") + await self.type_keys(proc, b"\x01[", 0.3) + mode_keys = self.cli("show-options", "-gv", "mode-keys") + await self.type_keys(proc, b"k", 0.2) + vi_up = self.cli("display-message", "-p", "#{pane_in_mode}") + await self.type_keys(proc, b"q", 0.3) + messages = self.cli("show-messages") + keys_c = self.cli("list-keys", "-T", "prefix", "c") + notes = self.cli("list-keys", "-N") + return {"top": top_line, "notice": notice_seen, "names": names, "pipe": panes_after_pipe, "old": windows_after_old_prefix, + "prefix2": windows_after_prefix2, "root": current_after_root, "mode_keys": mode_keys, "vi_up": vi_up, + "messages": messages, "keys_c": keys_c, "notes": notes, "cli_prefix": self.cli("show-options", "-gv", "prefix")} + + async def verify(self, result): + if "<0>" not in result["top"]: + raise TestAssertionFailed("status-left / status-position top not applied: %r" % result["top"]) + if not result["notice"]: + raise TestAssertionFailed("config problems were not announced on the status line") + self.expect("prefix changed", result["cli_prefix"], "C-a") + if "2:custom" not in result["names"]: + raise TestAssertionFailed("custom c binding with base-index 1 did not create window 2:custom: %r" % result["names"]) + self.expect("| split binding", result["pipe"], 2) + self.expect("old prefix C-b is unbound (no new window)", result["old"], 2) + self.expect("prefix2 F12 works", result["prefix2"], 3) + self.expect("root-table M-Right moves to the next window (wraps to 1)", result["root"], "1") + self.expect("mode-keys vi", result["mode_keys"], "vi") + self.expect("vi k stays in copy mode", result["vi_up"], "1") + for needle in ("no-such-option", "keys.prefix.broken"): + if needle not in result["messages"]: + raise TestAssertionFailed("show-messages does not name the bad config entry %r: %r" % (needle, result["messages"])) + if "new-window -n custom" not in result["keys_c"]: + raise TestAssertionFailed("list-keys does not show the rebound c: %r" % result["keys_c"]) + if "custom window" not in result["notes"]: + raise TestAssertionFailed("list-keys -N does not show the binding note") + + +class MplexMultiClientTest(MplexTestBase): + """Two terminals on one session: both repaint, the smaller one sets the size, input from either lands.""" + + name = "mplex-multi-client" + timeout = 60.0 + + async def run(self): + proc, screen = await self.attach() + await self.wait_for("first prompt", lambda: "0:bash*" in self.status(screen)) + self.rows, self.cols = 18, 60 + proc2, screen2 = await self.attach("attach") + await self.wait_for("second client painted", lambda: "0:bash*" in self.status(screen2)) + await asyncio.sleep(0.4) + size = self.cli("display-message", "-p", "#{window_width}x#{window_height}") + attached = self.cli("display-message", "-p", "#{session_attached}") + os.write(proc2._master_fd, b"echo from-second-$((1+1))\r") + await self.wait_for("first client sees the second one's command", lambda: "from-second-2" in screen.text()) + await self.type_keys(proc2, PREFIX + b"c", 0.4) + first_follows = await self.wait_for("first client follows the new window", lambda: "1:bash*" in self.status(screen)) + await self.type_keys(proc2, PREFIX + b"d") + await proc2.wait(timeout=10.0) + await asyncio.sleep(0.5) + size_after = self.cli("display-message", "-p", "#{window_width}x#{window_height}") + return {"size": size, "attached": attached, "follows": first_follows, "size_after": size_after} + + async def verify(self, result): + self.expect("window size is the smallest client", result["size"], "60x17") + self.expect("session_attached", result["attached"], "2") + self.expect("size grows back after the small client detaches", result["size_after"], "80x23") + + +class MplexCliTest(MplexTestBase): + """The non-attached surface: detached sessions, targets, send-keys, capture-pane, errors with exit codes.""" + + name = "mplex-cli" + timeout = 60.0 + + async def run(self): + out = {} + out["new"] = self.cli("new-session", "-d", "-s", "work", "-x", "100", "-y", "30", "-P", "-F", "#{session_name}:#{window_width}x#{window_height}") + self.cli("split-window", "-h", "-t", "work") + self.cli("new-window", "-t", "work", "-n", "logs") + self.cli("send-keys", "-t", "work:logs", "echo cli-$((6*7))", "Enter") + await asyncio.sleep(0.8) + out["capture"] = self.cli("capture-pane", "-p", "-t", "work:logs") + out["windows"] = self.cli("list-windows", "-t", "work", "-F", "#{window_index}:#{window_name}:#{window_panes}").replace("\n", " ") + out["panes"] = self.cli("list-panes", "-t", "work:0", "-F", "#{pane_index}:#{pane_width}").replace("\n", " ") + self.cli("set-option", "-g", "status-left", "[X] ") + out["option"] = self.cli("show-options", "-gv", "status-left") + self.cli("set-buffer", "-b", "named", "buffer text") + out["buffer"] = self.cli("show-buffer", "-b", "named") + self.cli("bind-key", "-T", "prefix", "F9", "display-message", "hi", "\\;", "list-windows") + out["bound"] = self.cli("list-keys", "-T", "prefix", "F9") + self.cli("unbind-key", "-T", "prefix", "F9") + out["unbound"] = self.cli("list-keys", "-T", "prefix", "F9") + out["bad_cmd"] = self.cli("no-such-command", check=False) + out["unsupported"] = self.cli("display-menu", check=False) + out["missing"] = self.cli("has-session", "-t", "nope", check=False) + done = subprocess.run([sys.executable, str(self.tai_path), "--mplex", "has-session", "-t", "nope"], env=self.cli_env(), + stdin=subprocess.DEVNULL, capture_output=True, text=True) + out["rc_missing"] = done.returncode + self.cli("new-window", "-d", "-t", "work", "-n", "first", ";", "new-window", "-d", "-t", "work", "-n", "second") + out["sequence"] = self.cli("list-windows", "-t", "work", "-F", "#{window_name}").replace("\n", " ") + self.cli("kill-window", "-t", "work:first", ";", "kill-window", "-t", "work:second") + self.cli("kill-window", "-t", "work:logs") + out["after_kill"] = self.cli("list-windows", "-t", "work", "-F", "#{window_index}") + self.cli("kill-session", "-t", "work") + out["empty"] = self.cli("list-sessions", check=False) + return out + + async def verify(self, result): + self.expect("new-session -d -x 100 -y 30 gives a 100x30 window, as tmux does", result["new"], "work:100x30") + if "cli-42" not in result["capture"]: + raise TestAssertionFailed("send-keys / capture-pane round trip failed: %r" % result["capture"][-300:]) + self.expect("windows", result["windows"], "0:bash:2 1:logs:1") + self.expect("split widths", result["panes"], "0:50 1:49") + self.expect("set-option / show-options", result["option"], "[X]") + self.expect("buffers", result["buffer"], "buffer text") + if "display-message hi" not in result["bound"] or "list-windows" not in result["bound"]: + raise TestAssertionFailed("bind-key with ; did not keep both commands: %r" % result["bound"]) + self.expect("unbind", result["unbound"], "") + for key, needle in (("bad_cmd", "unknown command"), ("unsupported", "not supported"), ("missing", "can't find session")): + if needle not in result[key]: + raise TestAssertionFailed("%s did not report %r: %r" % (key, needle, result[key])) + self.expect("exit code of a failing command", result["rc_missing"], 1) + self.expect("a lone ; runs two commands in order", result["sequence"], "bash logs first second") + self.expect("kill-window", result["after_kill"], "0") + + +ALL_CASES = [MplexLifecycleTest(), MplexShortcutsTest(), MplexCopyModeTest(), MplexConfigTest(), MplexMultiClientTest(), MplexCliTest()] + +if __name__ == "__main__": + sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs"))) diff --git a/tests/plexer.py b/tests/plexer.py index 74e1678..00dcb2b 100644 --- a/tests/plexer.py +++ b/tests/plexer.py @@ -1,22 +1,24 @@ #!/usr/bin/env python3 -"""tests/plexer.py — integration tests for tai's Plexer subsystem (NativePlexerBackend). +"""tests/plexer.py - integration tests for tai's Plexer subsystem (the mplex backend and its control socket). Deterministic throughout: this drives tai.py --plexer-wrap, a plumbing mode -that forks a real shell onto a real pty with no LLM involvement at all, so -"test with the real AI" (tests/README.md) doesn't apply here the way it does -to tests/rag.py — there is no model decision anywhere in this path to test. -What's real instead is the pty/signal/socket mechanics themselves: the whole -point of this file is that it exercises the actual `tai.py --plexer-wrap` -binary under a real pty (via tai_test.py's own pty mode — testing a pty -wrapper by driving it through another pty), not mocked termios/signal calls. +that starts the mplex server and attaches a real shell on a real pty with no LLM +involved at all, so "test with the real AI" (tests/README.md) doesn't apply here +the way it does to tests/rag.py - there is no model decision anywhere in this +path to test. What's real instead is the pty/signal/socket mechanics themselves: +the whole point of this file is that it exercises the actual +`tai.py --plexer-wrap` binary under a real pty (via tai_test.py's own pty mode - +testing a pty wrapper by driving it through another pty), not mocked +termios/signal calls. The multiplexer's shortcuts and config live in tests/mplex.py; +this file covers what the agent tools rely on: the control socket, per-pane +targeting, file permissions and the session log. -A real bug was caught by actually running this, not by reading the code: an -external SIGTERM (and SIGHUP) does not terminate an interactive bash running -as a pty.fork() session leader in this environment -- verified with a bare -pty.fork()+bash+os.kill script, independent of any tai.py code. -NativePlexerBackend's Ctrl+A,q shortcut uses SIGKILL specifically because of -this, not out of caution -- SIGTERM was tried first and silently did nothing -for ~10s in a real run. +A real bug was caught by actually running the original version of this test, not +by reading the code: an external SIGTERM (and SIGHUP) does not terminate an +interactive bash running as a pty.fork() session leader in this environment - +verified with a bare pty.fork()+bash+os.kill script, independent of any tai.py +code. The server therefore ends a pane with SIGHUP to its group, closes the pty, +and falls back to SIGKILL after MPLEX_REAP_KILL_AFTER seconds. """ import glob import os @@ -58,35 +60,49 @@ async def _drain_forever(proc): pass +def _mplex_cli(test, *words): + """Run `tai.py --mplex ` against the test's throwaway TAI_HOME (no tty, no attach).""" + import subprocess + env = dict(os.environ) + env.update(test.env_overrides) + for name in (tai.PLEXER_ACTIVE_ENV, tai.PLEXER_SOCKET_ENV, tai.PLEXER_PANE_ENV, "TMUX"): + env.pop(name, None) + done = subprocess.run([sys.executable, str(test.tai_path), "--mplex"] + list(words), env=env, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=30) + return done.returncode, (done.stdout + done.stderr).strip() + + +def _pane_logs(home): + return sorted(path for path in glob.glob(os.path.join(home, "plexer", "*-pane*.log"))) + + class NativePlexerBackendTest(TestCase): """Spawns `tai.py --plexer-wrap` under a real pty (tai_test's pty mode), then drives it as an external controller would: over its control socket, plus raw keystrokes written straight to the outer pty master to exercise - the Ctrl+A prefix-key path exactly as a human's terminal would deliver it.""" + the Ctrl+B prefix path exactly as a human's terminal would deliver it.""" name = "plexer-native-backend" mode = "pty" - timeout = 30.0 + timeout = 40.0 async def setup(self): self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-") - self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""} + self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": "", "TERM": "xterm-256color", "SHELL": "/bin/bash"} async def run(self): proc = await self.spawn(extra_args=["--plexer-wrap"]) self._drain_task = asyncio.ensure_future(_drain_forever(proc)) - sock_path = None + sock_path = os.path.join(self.tmp_home, "plexer", tai.MPLEX_SOCKET_NAME) for _ in range(50): - matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.sock")) - if matches: - sock_path = matches[0] + if os.path.exists(sock_path): break await asyncio.sleep(0.2) - if not sock_path: - raise TestAssertionFailed("NativePlexerBackend never created its control socket") + else: + raise TestAssertionFailed("the mplex server never created its control socket") controller = tai.NativePlexerController(sock_path) - await asyncio.sleep(0.3) + await asyncio.sleep(0.8) status = controller.status() child_pid = status.get("pid") @@ -109,12 +125,12 @@ class NativePlexerBackendTest(TestCase): import termios fcntl.ioctl(proc._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 17, 66, 0, 0)) os.kill(proc.proc.pid, tai.signal.SIGWINCH) - await asyncio.sleep(0.4) + await asyncio.sleep(0.5) controller.send_keys("stty size", enter=True) await asyncio.sleep(0.4) after_resize = controller.read_pane(50) - if "17 66" not in after_resize: - raise TestAssertionFailed("SIGWINCH resize was not forwarded to the inner shell: %r" % after_resize[-300:]) + if "16 66" not in after_resize: + raise TestAssertionFailed("SIGWINCH resize was not forwarded to the pane (17 rows minus the status row): %r" % after_resize[-300:]) controller.send_keys("echo contains_a_d_character", enter=True) await asyncio.sleep(0.3) @@ -122,45 +138,59 @@ class NativePlexerBackendTest(TestCase): if "contains_a_d_character" not in after_plain: raise TestAssertionFailed("a plain command containing the letter 'd' was mangled by prefix-key handling") - os.write(proc._master_fd, tai.PLEXER_PREFIX_KEY) - os.write(proc._master_fd, b"q") - await proc.wait(timeout=10.0) - await asyncio.sleep(0.2) + os.write(proc._master_fd, tai.MplexKeys.to_bytes("C-b") + b"d") + client_code = await proc.wait(timeout=10.0) + await asyncio.sleep(0.3) socket_survived = os.path.exists(sock_path) - import subprocess - still_running = subprocess.run(["ps", "-p", str(child_pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + alive_after_detach = subprocess.run(["ps", "-p", str(child_pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 - log_matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.log")) log_has_marker = False - if log_matches: - with open(log_matches[0], "rb") as handle: + logs = _pane_logs(self.tmp_home) + if logs: + with open(logs[0], "rb") as handle: log_has_marker = b"plexer_test_marker" in handle.read() + _mplex_cli(self, "kill-server") + for _ in range(40): + if not os.path.exists(sock_path): + break + await asyncio.sleep(0.1) + await asyncio.sleep(0.5) + socket_after_kill = os.path.exists(sock_path) + alive_after_kill = subprocess.run(["ps", "-p", str(child_pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + return { + "client_code": client_code, "socket_survived": socket_survived, - "still_running": still_running, + "alive_after_detach": alive_after_detach, "log_has_marker": log_has_marker, + "socket_after_kill": socket_after_kill, + "alive_after_kill": alive_after_kill, } async def verify(self, result): - if result["socket_survived"]: - raise TestAssertionFailed("control socket file was not cleaned up after Ctrl+A,q") - if result["still_running"]: - raise TestAssertionFailed("the wrapped shell process was still alive after Ctrl+A,q (leaked process)") + if result["client_code"] != 0: + raise TestAssertionFailed("detaching (C-b d) should end the client with exit code 0, got %r" % result["client_code"]) + if not result["socket_survived"] or not result["alive_after_detach"]: + raise TestAssertionFailed("detach must leave the server and the shell running: %r" % result) if not result["log_has_marker"]: raise TestAssertionFailed("the continuous session log did not capture the session's actual output") + if result["socket_after_kill"]: + raise TestAssertionFailed("control socket file was not cleaned up after kill-server") + if result["alive_after_kill"]: + raise TestAssertionFailed("the pane's shell process was still alive after kill-server (leaked process)") async def teardown(self): self._drain_task.cancel() + _mplex_cli(self, "kill-server") shutil.rmtree(self.tmp_home, ignore_errors=True) class NativePlexerBackendRobustnessTest(TestCase): - """Targets three specific bugs found and fixed during a deliberate review - pass after NativePlexerBackendTest first went green -- a happy-path test - passing does not mean the code is reliable, and all three of these would - have sailed through that test unnoticed: + """Targets specific bugs found and fixed during a deliberate review pass after + the happy-path test first went green - a happy-path test passing does not + mean the code is reliable, and all of these would have sailed through it: 1. File permissions: the control socket and the session log (which captures the ENTIRE terminal session verbatim -- anything typed, @@ -178,8 +208,8 @@ class NativePlexerBackendRobustnessTest(TestCase): the pty master from inside the control-request handler. For a large payload on an echo-enabled pty, that write can block waiting for buffer space that only frees up once something reads the echoed - output back out -- which is this very relay loop's own job, and it - was the one blocked. Fixed by queuing all writes-to-child and only + output back out -- which is the server loop's own job, and it was + the one blocked. Fixed by queuing all writes-to-child and only ever draining them from the main select() loop, cooperatively with reading the child's output, never as a blocking write from inside request handling. @@ -187,32 +217,31 @@ class NativePlexerBackendRobustnessTest(TestCase): name = "plexer-native-backend-robustness" mode = "pty" - timeout = 30.0 + timeout = 40.0 async def setup(self): self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-") - self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""} + self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": "", "TERM": "xterm-256color", "SHELL": "/bin/bash"} async def run(self): proc = await self.spawn(extra_args=["--plexer-wrap"]) self._drain_task = asyncio.ensure_future(_drain_forever(proc)) - sock_path = None + sock_path = os.path.join(self.tmp_home, "plexer", tai.MPLEX_SOCKET_NAME) for _ in range(50): - matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.sock")) - if matches: - sock_path = matches[0] + if os.path.exists(sock_path): break await asyncio.sleep(0.2) - if not sock_path: - raise TestAssertionFailed("NativePlexerBackend never created its control socket") + else: + raise TestAssertionFailed("the mplex server never created its control socket") controller = tai.NativePlexerController(sock_path) - await asyncio.sleep(0.3) + await asyncio.sleep(0.8) plexer_dir_path = os.path.dirname(sock_path) - log_matches = glob.glob(os.path.join(plexer_dir_path, "*.log")) - if not log_matches: - raise TestAssertionFailed("no log file found to check permissions on") - log_path = log_matches[0] + controller.status() + logs = _pane_logs(self.tmp_home) + if not logs: + raise TestAssertionFailed("no pane log file found to check permissions on") + log_path = logs[0] dir_mode = os.stat(plexer_dir_path).st_mode & 0o777 sock_mode = os.stat(sock_path).st_mode & 0o777 @@ -226,7 +255,7 @@ class NativePlexerBackendRobustnessTest(TestCase): padding = "x" * 90000 large_text = "echo %s%s%s" % (needle_head, padding, needle_tail) controller.send_keys(large_text, enter=True) - await asyncio.sleep(0.6) + await asyncio.sleep(0.8) large_result = controller.read_pane(2000) # Malformed control request: connect directly to the socket (bypassing @@ -243,9 +272,14 @@ class NativePlexerBackendRobustnessTest(TestCase): raw_sock.close() malformed_reply = json_module.loads(malformed_reply_raw.decode("utf-8", "replace")) - # The backend must still be alive and responsive after that malformed - # request -- this is the actual point of the test, not the error - # shape above. + # Garbage that is neither a frame nor a line must not hang or kill the server either. + junk = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM) + junk.connect(sock_path) + junk.sendall(b"\xff\xff\xff not json at all") + junk.close() + + # The server must still be alive and responsive after those requests - + # this is the actual point of the test, not the error shape above. status_after = controller.status() controller.send_keys("exit", enter=True) @@ -276,14 +310,77 @@ class NativePlexerBackendRobustnessTest(TestCase): raise TestAssertionFailed("a malformed control request did not come back as a clean error: %r" % result["malformed_reply"]) if not result["status_after_pid"] or result["status_after_pid"] <= 0: - raise TestAssertionFailed("backend did not survive the malformed request -- a bad control message must never crash the whole wrapped session") + raise TestAssertionFailed("server did not survive the malformed requests -- a bad control message must never crash the whole session") async def teardown(self): self._drain_task.cancel() + _mplex_cli(self, "kill-server") shutil.rmtree(self.tmp_home, ignore_errors=True) -ALL_CASES = [NativePlexerBackendTest(), NativePlexerBackendRobustnessTest()] +class PlexerPaneTargetingTest(TestCase): + """With several panes in one server, the agent tools must reach the pane they were started in: + the controller sends the pane id from $TAI_PLEXER_PANE, and an unknown pane is an error, not a guess.""" + + name = "plexer-pane-targeting" + mode = "pty" + timeout = 40.0 + + async def setup(self): + self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-") + self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": "", "TERM": "xterm-256color", "SHELL": "/bin/bash"} + + async def run(self): + proc = await self.spawn(extra_args=["--plexer-wrap"]) + self._drain_task = asyncio.ensure_future(_drain_forever(proc)) + sock_path = os.path.join(self.tmp_home, "plexer", tai.MPLEX_SOCKET_NAME) + for _ in range(50): + if os.path.exists(sock_path): + break + await asyncio.sleep(0.2) + await asyncio.sleep(0.8) + code, out = _mplex_cli(self, "split-window", "-h", "-P", "-F", "#{pane_id}") + await asyncio.sleep(0.8) + first = tai.NativePlexerController(sock_path, "0") + second = tai.NativePlexerController(sock_path, out.strip().lstrip("%")) + first.send_keys("echo only-in-first", enter=True) + second.send_keys("echo only-in-second", enter=True) + await asyncio.sleep(0.6) + first_text, second_text = first.read_pane(50), second.read_pane(50) + env_seen = [] + for controller, label in ((first, "first"), (second, "second")): + controller.send_keys("echo pane=$TAI_PLEXER_PANE active=$TAI_PLEXER_ACTIVE", enter=True) + await asyncio.sleep(0.6) + env_first, env_second = first.read_pane(50), second.read_pane(50) + bad = None + try: + tai.NativePlexerController(sock_path, "999").status() + except tai.PlexerError as exc: + bad = str(exc) + return {"first": first_text, "second": second_text, "env_first": env_first, "env_second": env_second, "bad": bad, + "split_id": out.strip(), "split_code": code} + + async def verify(self, result): + if result["split_code"] != 0 or not result["split_id"].startswith("%"): + raise TestAssertionFailed("split-window -P did not report the new pane: %r" % result) + if "only-in-first" not in result["first"] or "only-in-second" in result["first"].replace("echo only-in-second", ""): + raise TestAssertionFailed("pane 0 output is wrong: %r" % result["first"][-300:]) + if "only-in-second" not in result["second"] or "only-in-first" in result["second"].replace("echo only-in-first", ""): + raise TestAssertionFailed("second pane output is wrong: %r" % result["second"][-300:]) + if "pane=0 active=1" not in result["env_first"]: + raise TestAssertionFailed("pane 0 does not see TAI_PLEXER_PANE=0: %r" % result["env_first"][-200:]) + if "pane=%s active=1" % result["split_id"].lstrip("%") not in result["env_second"]: + raise TestAssertionFailed("second pane does not see its own TAI_PLEXER_PANE: %r" % result["env_second"][-200:]) + if not result["bad"] or "no such pane" not in result["bad"]: + raise TestAssertionFailed("an unknown pane id must be an error, got %r" % result["bad"]) + + async def teardown(self): + self._drain_task.cancel() + _mplex_cli(self, "kill-server") + shutil.rmtree(self.tmp_home, ignore_errors=True) + + +ALL_CASES = [NativePlexerBackendTest(), NativePlexerBackendRobustnessTest(), PlexerPaneTargetingTest()] if __name__ == "__main__": sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs"))) diff --git a/tui.md b/tui.md index 2429a78..dde485f 100644 --- a/tui.md +++ b/tui.md @@ -423,6 +423,12 @@ the lazy pipeline and was not re-measured). ## Terminal widget notes +`TuiVtScreen` also serves the multiplexer, so it keeps scrollback (`history`, a bounded +deque set by the owner; only full-screen scrolls outside the alternate screen feed it), +answers cursor-position and device-attribute queries (`take_replies()`), tracks +application cursor keys, bracketed paste and the window title, draws the DEC line +charset, repeats characters (`CSI b`) and keeps italic, blink and hidden attributes. + `spawn()` forks the command onto a pty sized to the widget; resizes propagate via SIGWINCH-winning `TIOCSWINSZ`. Input queues and drains cooperatively with output reads (never a blocking write from a @@ -434,6 +440,9 @@ the widget shows a plain error line instead of crashing. - Wide (CJK/emoji) characters count as one column; columns around them misalign. Structured to fix with a width table later. +- `TuiVtScreen` does not reflow on resize: narrowing a screen drops the + columns that no longer fit, and widening does not bring them back. The + multiplexer (`mplex.md`) inherits this. - The theme layer is role names plus a registry, not a full stylesheet language (no selectors, no file-based overrides); only the key-event subset of the kitty protocol (no release tracking,