Update.
This commit is contained in:
@@ -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.<Class>.<name>` (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/<topic>.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
|
||||
|
||||
|
||||
@@ -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 <command>`. 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.
|
||||
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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. |
|
||||
|
||||
@@ -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.<table>]`; 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. |
|
||||
@@ -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 <command>`; 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. |
|
||||
+1865
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
|
||||
+578
@@ -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 <command>`
|
||||
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")))
|
||||
+164
-67
@@ -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 <words>` 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")))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user