From 5063c5e71e9804cceb645da5a7a37d8f11d37a8c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:12:31 +0530 Subject: [PATCH 1/3] Document and test multiplexer interoperability Research for #175: a behavior matrix for NMSh inside and around tmux and GNU screen, verified on tmux 3.7c and screen 4.00 in isolated sandboxes, with Zellij and TUIOS marked unverified. It covers rendering, resize propagation, job control, fullscreen passthrough, bracketed paste, mouse and key encodings, clipboard, and how multiplexer persistence and NMSh live sessions overlap: a closed pane detaches the live session, and a reattached session keeps the environment it was created in. Baseline coexistence needs no multiplexer detection. Prioritized follow-ups cover a reattach notice for environment changes, Shift+Enter under default tmux settings, Open all from inside a multiplexer, a Zellij pass, and recommended tmux settings. tests/muxInterop.test.ts pins the verified contract with real tmux and screen where installed and skips otherwise, plus host-detection cases for tmux, screen and Zellij environments. --- CHANGELOG.md | 1 + docs/architecture/multiplexer-interop.md | 57 +++++++++ tests/muxInterop.test.ts | 143 +++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 docs/architecture/multiplexer-interop.md create mode 100644 tests/muxInterop.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 746fb48..acfad7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Multiplexer interoperability notes:** [docs/architecture/multiplexer-interop.md](docs/architecture/multiplexer-interop.md) records how NMSh behaves inside and around tmux and GNU screen (rendering, resize, job control, fullscreen, paste, and live-session detach when a pane closes), what remains unverified (Zellij), and follow-ups. NMSh stays independent of any multiplexer. - **Live session status:** `/resume` LIVE rows and `nmsh --list` show what each live session is doing, from evidence only: the running command and elapsed time, the foreground process or a known CLI's name (Claude Code, Codex, Aider, …), active or quiet output, *needs attention* when the program sent a terminal notification or bell, fullscreen, the title it set, and the last command's result while idle. Nothing is guessed, and paths under your home directory are shown as `~/…`. - **`/layout` showcase:** preview composer position (Bottom, Top, Flow) × transcript presentation (Normal, Chat) on sample content through the real renderer, then save and apply live. Also under Config → Layout. Nothing in the preview runs or reaches the transcript, journal or `/copy`. - **Flow composer:** Config → Composer position → Flow (the command palette's Toggle composer position cycles Bottom, Top and Flow). The prompt and input follow the newest output inside NMSh's document, like a conventional terminal, and scroll with it. Typing while scrolled back returns to them; scrolling alone does not. Menus open below the input, panels pin to the bottom, and Chat presentation and fullscreen passthrough work as before. diff --git a/docs/architecture/multiplexer-interop.md b/docs/architecture/multiplexer-interop.md new file mode 100644 index 0000000..7de3f47 --- /dev/null +++ b/docs/architecture/multiplexer-interop.md @@ -0,0 +1,57 @@ +# Terminal multiplexer interoperability (#175) + +NMSh is a terminal frontend over zsh and a real PTY. It runs inside and alongside terminal multiplexers, but it is not one: it never launches, wraps, nests or manages tmux, Zellij or screen, and no multiplexer is a dependency. This note records how NMSh behaves with them today, what was verified and how, and the follow-ups worth doing. Scope stays reliable coexistence; general CLI/TUI compatibility is #14 and agent terminal hosts are #15. + +## How it was verified + +Verified on macOS with tmux 3.7c and GNU screen 4.00.03, in isolated sandboxes (own HOME, config and session-service runtime directory): +- NMSh inside tmux was driven with `tmux send-keys`, `paste-buffer -p`, `resize-window` and read with `capture-pane`. +- NMSh inside screen and screen inside NMSh were driven through node-pty. +- The contract below marked **tested** is pinned in `tests/muxInterop.test.ts`. Those tests skip themselves where tmux or screen is not installed, so CI never depends on a multiplexer. + +Zellij and TUIOS were not available and are marked **unverified**; the expectations for them are reasoning from how they work, not observations. + +## Behavior matrix + +| Situation | Behavior | Status | +| --- | --- | --- | +| NMSh inside tmux: rendering | Normal NMSh screen. The managed zsh sees `TERM=tmux-256color`, `TERM_PROGRAM=tmux` and `TMUX`. | tested | +| NMSh inside tmux: resize | tmux resizes the pane; NMSh gives the shell the pane minus its composer rows (observed 100×30 → `25 100`, 120×40 → `35 120`; the exact rows depend on the prompt's height). | tested | +| NMSh inside tmux: Ctrl+Z / job control | Suspends the foreground job; `jobs`, `fg` work. | tested | +| NMSh inside tmux: fullscreen apps (`less`, `vim`) | Passthrough, then NMSh repaints its composer. | tested | +| NMSh inside tmux: bracketed paste | A multi-line `paste-buffer -p` lands in the composer as a paste; nothing runs. | tested | +| NMSh inside tmux: mouse | NMSh requests SGR mouse (1000/1003/1006). tmux forwards mouse events only with `set -g mouse on`; otherwise the wheel is tmux's (copy mode/scrollback). | expected, physical QA | +| NMSh inside tmux: keys | NMSh asks for the kitty keyboard protocol, which tmux does not honor. NMSh already decodes both extended encodings tmux can forward (`CSI 27;2;13~`, `CSI 13;2u`), but tmux's default `extended-keys off`, and `on` without a modifyOtherKeys request, deliver Shift+Enter as plain Enter. `set -g extended-keys always` should make Shift+Enter insert a newline. | expected, physical QA; see follow-up 2 | +| NMSh inside tmux: `/copy` | Uses `pbcopy`, which works inside tmux on current macOS. OSC 52 is not used, so tmux `set-clipboard` does not matter. | reasoned | +| NMSh inside tmux: pane closed or tmux killed | The frontend gets SIGHUP and **detaches**: the live session keeps running in nmshd and appears in `nmsh --sessions`, `/resume` and the startup restore prompt. | tested | +| NMSh inside tmux: tmux client detached | The pane (and NMSh) keep running; the live session stays **attached**, so other NMSh windows never offer or take it. Reattaching tmux shows the same NMSh. | reasoned from tmux semantics | +| tmux inside NMSh | Enters the alternate screen, so NMSh passes the terminal through; it repaints after reattach, and the app's mouse modes are restored on reattach (#131). | tested (existing `liveHardening` test) | +| tmux inside NMSh inside tmux | tmux refuses to nest an attached client while `TMUX` is set, as in any shell. Unchanged by NMSh. | reasoned | +| NMSh inside GNU screen | Rendering, resize (120×40 → `35 120`), Ctrl+Z, `less` and return all work. | tested | +| GNU screen inside NMSh | screen switches to the alternate screen, so NMSh passes it through and returns cleanly. screen's very first size query can report the composer-reduced size; the SIGWINCH from NMSh's passthrough resize corrects it. | tested | +| Zellij (either direction) | Expected like tmux: a PTY-backed multiplexer that forwards resize and uses the alternate screen, with its own mouse and keyboard handling. Its kitty keyboard support may differ from tmux. | unverified | +| TUIOS / terminal-native splits | Splits in Ghostty, kitty or WezTerm are separate terminals to NMSh; each NMSh is independent. | unverified | + +## Where persistence overlaps + +tmux and NMSh can both keep a shell alive, at different layers: +- tmux keeps the **frontend process** (NMSh itself) alive while tmux runs; detaching a tmux client changes nothing for NMSh. +- nmshd keeps the **shell** alive when the frontend goes away (pane closed, tmux killed, window closed) as a detached live session. + +They compose rather than conflict, because NMSh never inspects or depends on tmux state and a session attached in a tmux pane is never offered to, or taken over by, another NMSh window. The one real wrinkle is the environment: + +**A live session keeps the environment it was created in.** A session started inside tmux and later reattached from a plain Ghostty window (tested) still has `TERM=tmux-256color`, `TERM_PROGRAM=tmux` and `TMUX` pointing at a tmux server that may be gone. The same applies in reverse: a session started in Ghostty and reattached inside tmux believes it is directly in Ghostty. A shell cannot have its environment replaced from outside, and #127 deliberately gives each session its creating frontend's environment. Effects are usually small (`tmux` inside that shell may refuse to nest; programs may pick capabilities for the wrong terminal), but they are invisible to the user. See follow-up 1. + +Startup restore (#197) treats these sessions like any other. Its window launcher detects Ghostty from `GHOSTTY_RESOURCES_DIR`, which tmux inherits from the Ghostty that started it, so "Open all" from inside tmux opens new **Ghostty windows outside tmux** rather than tmux windows (tested at the detection level). That is safe, since each window just runs `nmsh --attach`, but it may not be what a tmux user expects. See follow-up 3. + +## Is environment detection needed? + +Not for baseline coexistence. Everything in the matrix works with no multiplexer detection, and unknown hosts keep the default behavior. Detection would only be justified for the targeted follow-ups below, and should use environment evidence (`TMUX`, `ZELLIJ`, `STY`, `TERM_PROGRAM`) rather than command names. + +## Prioritized follow-ups + +1. **Tell the user when a reattached session's environment differs.** When the attaching frontend's multiplexer or terminal (`TMUX`/`ZELLIJ`/`STY` presence, `TERM`, `TERM_PROGRAM`) differs from the session's creation environment, add one factual line to the reattach notice. No behavior change, just visibility. (P1) +2. **Shift+Enter inside tmux with default settings.** Also request modifyOtherKeys (`CSI > 4 ; 1 m`) alongside the kitty push, and pop it on exit, so tmux `extended-keys on` forwards Shift+Enter to NMSh without users changing tmux config. Verify physically in Ghostty and Terminal.app first. (P1) +3. **"Open all" from inside a multiplexer.** Decide whether startup restore should open tmux windows (`tmux new-window 'nmsh --attach …'`) when `TMUX` is set, or keep opening host windows. This is a product choice for the #197 host abstraction, not a bug. (P2) +4. **Physical Zellij pass.** Run the matrix in Zellij and record results; add Zellij to the `muxInterop` tests if it can be installed where tests run. (P2) +5. **Document recommended tmux settings** (`mouse on`, `extended-keys always`) in the README once 2 is decided. (P3) diff --git a/tests/muxInterop.test.ts b/tests/muxInterop.test.ts new file mode 100644 index 0000000..25471bc --- /dev/null +++ b/tests/muxInterop.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {detectTerminalHost} from '../src/host/terminalHost.js'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; +import {inForeground, uniqueSleep} from './helpers/processState.js'; + +/** + * The multiplexer interoperability contract from docs/architecture/multiplexer-interop.md. + * Real tmux and screen are used where installed; otherwise these skip, so CI never depends on them. + */ + +const REPO = fileURLToPath(new URL('..', import.meta.url)); +const hasTmux = spawnSync('tmux', ['-V']).status === 0; +const hasScreen = spawnSync('screen', ['-v']).status !== null && spawnSync('which', ['screen']).status === 0; +const quote = (value: string) => `'${value.replace(/'/gu, `'\\''`)}'`; + +/** NMSh running inside a private tmux server, driven with send-keys and read with capture-pane. */ +class TmuxPane { + readonly socket = `nmsh-mux-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + constructor(private readonly sandbox: LiveSandbox, columns = 100, rows = 30) { + const env = Object.entries(sandbox.env).filter(([name, value]) => value !== undefined && /^(HOME|XDG_CONFIG_HOME|NMSH_[A-Z_]+|PATH)$/u.test(name)) + .map(([name, value]) => `${name}=${quote(value!)}`).join(' '); + this.tmux('new-session', '-d', '-x', String(columns), '-y', String(rows), '-s', 'p', + `cd ${quote(REPO)} && env ${env} ${quote(process.execPath)} --import=tsx src/index.ts`); + } + tmux(...args: string[]): string { + return spawnSync('tmux', ['-L', this.socket, '-f', '/dev/null', ...args], {encoding: 'utf8'}).stdout; + } + screen(): string { return this.tmux('capture-pane', '-p', '-t', 'p'); } + keys(...keys: string[]): void { this.tmux('send-keys', '-t', 'p', ...keys); } + async waitFor(pattern: RegExp, what = String(pattern)): Promise { + await until(() => pattern.test(this.screen()), 20000, () => `${what}; screen:\n${this.screen()}`); + } + async run(command: string, expect: RegExp): Promise { + this.keys(command, 'Enter'); + await this.waitFor(expect); + } + kill(): void { this.tmux('kill-server'); } +} + +test('NMSh inside tmux: environment, resize, job control, fullscreen, paste; closing the pane detaches the live session', + {skip: hasTmux ? false : 'tmux is not installed'}, async () => { + const sandbox = new LiveSandbox(); + const pane = new TmuxPane(sandbox); + try { + await pane.waitFor(/❯/, 'composer'); + await pane.run('echo "IN=$TERM/$TERM_PROGRAM/${TMUX:+tmux}/$NMSH_SESSION_MODE"', /IN=tmux-256color\/tmux\/tmux\/service/); + + // The pane minus NMSh's composer rows (their count depends on the prompt), and it follows tmux resizes. + const size = async () => { + const mark = `SIZE${Math.random().toString(36).slice(2, 7)}`; + await pane.run(`echo ${mark}-$(stty size | tr " " x)`, new RegExp(`${mark}-\\d+x\\d+`)); + const [, rows, columns] = new RegExp(`${mark}-(\\d+)x(\\d+)`).exec(pane.screen())!; + return {rows: Number(rows), columns: Number(columns)}; + }; + const before = await size(); + assert.equal(before.columns, 100); + assert.ok(before.rows >= 20 && before.rows < 30, `rows ${before.rows}`); + pane.tmux('resize-window', '-t', 'p', '-x', '120', '-y', '40'); + await until(async () => (await size()).columns === 120, 20000, 'resize reached the shell'); + const after = await size(); + assert.ok(after.rows >= before.rows + 8 && after.rows < 40, `rows ${before.rows} -> ${after.rows}`); + + const duration = uniqueSleep(175); + pane.keys(`sleep ${duration}`, 'Enter'); + await until(() => inForeground(duration), 20000, 'sleep in the foreground'); + pane.keys('C-z'); + await pane.run('jobs', /suspended\s+sleep/); + await pane.run('kill %1; echo JOB-GONE', /JOB-GONE/); + + pane.keys('seq 1 500 | less', 'Enter'); + await pane.waitFor(/^:\s*$/mu, 'less prompt'); + pane.keys('q'); + await pane.run('echo BACK-FROM-LESS', /BACK-FROM-LESS/); + + // Bracketed paste through tmux stays a paste in the composer: nothing runs. + pane.tmux('set-buffer', 'echo PASTED-ONE\necho PASTED-TWO'); + pane.tmux('paste-buffer', '-p', '-t', 'p'); + await pane.waitFor(/❯ echo PASTED-ONE\s*\n\s+echo PASTED-TWO/u, 'multi-line paste in the composer'); + assert.doesNotMatch(pane.screen(), /^PASTED-ONE$/mu, 'the pasted lines did not run'); + pane.keys('C-u'); + + const [live] = await sandbox.sessions(); + assert.equal(live?.state, 'attached'); + pane.kill(); + await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached after the pane closed'); + assert.equal((await sandbox.sessions())[0]!.id, live!.id, 'the same live session, restorable'); + } finally { + pane.kill(); + await sandbox.dispose(); + } + }); + +test('a live session keeps the environment it was created in: made in tmux, reattached outside it', + {skip: hasTmux ? false : 'tmux is not installed'}, async () => { + const sandbox = new LiveSandbox(); + const pane = new TmuxPane(sandbox); + try { + await pane.waitFor(/❯/, 'composer'); + await pane.run('echo READY', /READY/); + const [live] = await sandbox.sessions(); + pane.kill(); + await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached'); + + const outside = sandbox.launch(['--attach', live!.id]); + await outside.waitFor(/Reattached live session/); + await outside.run('echo "ENV=$TERM/$TERM_PROGRAM/${TMUX:+stale-tmux}"', /ENV=tmux-256color\/tmux\/stale-tmux/); + } finally { + pane.kill(); + await sandbox.dispose(); + } + }); + +test('GNU screen inside NMSh passes through and returns to the composer', {skip: hasScreen ? false : 'screen is not installed'}, async () => { + const sandbox = new LiveSandbox(); + try { + const app = sandbox.launch(); + await app.waitFor(/❯/); + const mark = app.mark; + app.pty.write(`screen -q -S nmsh-inner-${process.pid} zsh -f -c 'echo SCREEN-INNER; sleep 1'\r`); + await app.waitFor(/SCREEN-INNER/, mark); + await until(() => app.output.indexOf('\u001b[?1049l', mark) !== -1, 20000, 'screen left the alternate screen'); + assert.ok(app.output.indexOf('\u001b[?1049h', mark) !== -1, 'screen used the alternate screen, so NMSh passed it through'); + await app.waitFor(/❯/, app.output.indexOf('\u001b[?1049l', mark)); + await app.run('echo AFTER-SCREEN', /AFTER-SCREEN/); + } finally { + await sandbox.dispose(); + } +}); + +test('window-launch host detection inside a multiplexer uses inherited evidence (documented behavior)', () => { + // tmux inherits GHOSTTY_RESOURCES_DIR from the Ghostty that started it: "Open all" opens Ghostty windows outside tmux. + assert.equal(detectTerminalHost({TERM_PROGRAM: 'tmux', TMUX: '/tmp/tmux-501/default,1,0', GHOSTTY_RESOURCES_DIR: '/Applications/Ghostty.app/x'}, 'darwin').name, 'Ghostty'); + // Without that evidence a multiplexer is a host NMSh cannot open windows in, so it names the attach commands instead. + const plainTmux = detectTerminalHost({TERM_PROGRAM: 'tmux', TMUX: '/tmp/tmux-501/default,1,0'}, 'darwin'); + assert.equal(plainTmux.newWindow, undefined); + const screen = detectTerminalHost({TERM: 'screen', STY: '123.pts-0.host'}, 'linux'); + assert.equal(screen.newWindow, undefined); + const zellij = detectTerminalHost({ZELLIJ: '0', TERM_PROGRAM: ''}, 'linux'); + assert.equal(zellij.newWindow, undefined); +}); From 06203db8683aad3169db5f3ce2b5a6a99454ade0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:14:02 +0530 Subject: [PATCH 2/3] Wait for the background command before /resume in the status test The end-to-end status test typed /resume while 'sleep 30 &' was still finishing and NMSh correctly refused to switch transcripts mid-command. Wait for the command to complete first. --- tests/liveStatus.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/liveStatus.test.ts b/tests/liveStatus.test.ts index 5bf9d92..8be65fb 100644 --- a/tests/liveStatus.test.ts +++ b/tests/liveStatus.test.ts @@ -120,7 +120,8 @@ test('end to end: a CLI’s own title and notification reach /resume across deta const viewer = sandbox.launch(['--new']); await viewer.waitFor(/❯/); - await viewer.run('sleep 30 &', /❯/); + // Wait for the command to finish: /resume is refused while one runs. + await viewer.run('sleep 30 & echo BG-STARTED', /BG-STARTED[\s\S]*Completed/); const mark = viewer.mark; viewer.pty.write('/resume\r'); await viewer.waitFor(/Claude Code · needs attention[\s\S]*Fake agent: ready/, mark); From bf510954b73d636d334e3a8d0330190d0fe26f9f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:51:01 +0530 Subject: [PATCH 3/3] Separate tested, observed and expected multiplexer behavior Each row of the multiplexer matrix now states its evidence: automated test, manual observation or expected. The test file is described as coverage for selected tmux/screen paths, not the whole contract. Tests added where they fit the existing harness: closing only the tmux pane (server still running) detaches the live session; NMSh inside GNU screen renders, sees STY, follows a resize and suspends a job; a program on the alternate screen gets the full terminal in passthrough; and the stale-environment test confirms the tmux server is gone. tmux runs with multiplexer variables stripped from the inherited environment, and the screen-inside-NMSh test quits its screen, which detaches rather than exiting when its terminal closes. Corrected: screen 4.00 keeps the window size it started with, so inside NMSh it keeps the composer-reduced height (new follow-up 6); it was wrongly described as corrected by SIGWINCH. vim, fg and less inside screen are marked as manual. Stale TMUX/TERM in a reattached shell is documented as affecting later child commands, while the attaching frontend's host detection uses its own environment. --- docs/architecture/multiplexer-interop.md | 63 ++++++++------ tests/muxInterop.test.ts | 102 +++++++++++++++++++++-- 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/docs/architecture/multiplexer-interop.md b/docs/architecture/multiplexer-interop.md index 7de3f47..8941634 100644 --- a/docs/architecture/multiplexer-interop.md +++ b/docs/architecture/multiplexer-interop.md @@ -4,33 +4,38 @@ NMSh is a terminal frontend over zsh and a real PTY. It runs inside and alongsid ## How it was verified -Verified on macOS with tmux 3.7c and GNU screen 4.00.03, in isolated sandboxes (own HOME, config and session-service runtime directory): -- NMSh inside tmux was driven with `tmux send-keys`, `paste-buffer -p`, `resize-window` and read with `capture-pane`. -- NMSh inside screen and screen inside NMSh were driven through node-pty. -- The contract below marked **tested** is pinned in `tests/muxInterop.test.ts`. Those tests skip themselves where tmux or screen is not installed, so CI never depends on a multiplexer. +Checked on macOS with tmux 3.7c and GNU screen 4.00.03, in isolated sandboxes (own HOME, config and session-service runtime directory). Each row of the matrix says which kind of evidence supports it: -Zellij and TUIOS were not available and are marked **unverified**; the expectations for them are reasoning from how they work, not observations. +- **Automated test:** pinned in `tests/muxInterop.test.ts`, which gives automated contract coverage for *selected* tmux/screen paths. It uses real tmux and screen where they are installed and skips otherwise, so CI never depends on a multiplexer. The tests strip any multiplexer variables (`TMUX`, `TMUX_PANE`, `STY`, `WINDOW`, `ZELLIJ*`) from the environment they inherit, so an outer multiplexer cannot leak in. Nothing else is changed. +- **Manual observation:** seen during scripted probes for this research (tmux `send-keys`/`capture-pane`, node-pty), but not pinned by a test. +- **Expected:** inferred from how the multiplexer works, or not checkable here. Zellij and TUIOS were not available, so everything about them is expected, not observed. ## Behavior matrix -| Situation | Behavior | Status | +| Situation | Behavior | Evidence | | --- | --- | --- | -| NMSh inside tmux: rendering | Normal NMSh screen. The managed zsh sees `TERM=tmux-256color`, `TERM_PROGRAM=tmux` and `TMUX`. | tested | -| NMSh inside tmux: resize | tmux resizes the pane; NMSh gives the shell the pane minus its composer rows (observed 100×30 → `25 100`, 120×40 → `35 120`; the exact rows depend on the prompt's height). | tested | -| NMSh inside tmux: Ctrl+Z / job control | Suspends the foreground job; `jobs`, `fg` work. | tested | -| NMSh inside tmux: fullscreen apps (`less`, `vim`) | Passthrough, then NMSh repaints its composer. | tested | -| NMSh inside tmux: bracketed paste | A multi-line `paste-buffer -p` lands in the composer as a paste; nothing runs. | tested | -| NMSh inside tmux: mouse | NMSh requests SGR mouse (1000/1003/1006). tmux forwards mouse events only with `set -g mouse on`; otherwise the wheel is tmux's (copy mode/scrollback). | expected, physical QA | -| NMSh inside tmux: keys | NMSh asks for the kitty keyboard protocol, which tmux does not honor. NMSh already decodes both extended encodings tmux can forward (`CSI 27;2;13~`, `CSI 13;2u`), but tmux's default `extended-keys off`, and `on` without a modifyOtherKeys request, deliver Shift+Enter as plain Enter. `set -g extended-keys always` should make Shift+Enter insert a newline. | expected, physical QA; see follow-up 2 | -| NMSh inside tmux: `/copy` | Uses `pbcopy`, which works inside tmux on current macOS. OSC 52 is not used, so tmux `set-clipboard` does not matter. | reasoned | -| NMSh inside tmux: pane closed or tmux killed | The frontend gets SIGHUP and **detaches**: the live session keeps running in nmshd and appears in `nmsh --sessions`, `/resume` and the startup restore prompt. | tested | -| NMSh inside tmux: tmux client detached | The pane (and NMSh) keep running; the live session stays **attached**, so other NMSh windows never offer or take it. Reattaching tmux shows the same NMSh. | reasoned from tmux semantics | -| tmux inside NMSh | Enters the alternate screen, so NMSh passes the terminal through; it repaints after reattach, and the app's mouse modes are restored on reattach (#131). | tested (existing `liveHardening` test) | -| tmux inside NMSh inside tmux | tmux refuses to nest an attached client while `TMUX` is set, as in any shell. Unchanged by NMSh. | reasoned | -| NMSh inside GNU screen | Rendering, resize (120×40 → `35 120`), Ctrl+Z, `less` and return all work. | tested | -| GNU screen inside NMSh | screen switches to the alternate screen, so NMSh passes it through and returns cleanly. screen's very first size query can report the composer-reduced size; the SIGWINCH from NMSh's passthrough resize corrects it. | tested | -| Zellij (either direction) | Expected like tmux: a PTY-backed multiplexer that forwards resize and uses the alternate screen, with its own mouse and keyboard handling. Its kitty keyboard support may differ from tmux. | unverified | -| TUIOS / terminal-native splits | Splits in Ghostty, kitty or WezTerm are separate terminals to NMSh; each NMSh is independent. | unverified | +| NMSh inside tmux: environment | The managed zsh sees `TERM=tmux-256color`, `TERM_PROGRAM=tmux` and `TMUX`. | Automated test | +| NMSh inside tmux: resize | tmux resizes the pane, and NMSh gives the shell the pane minus its composer rows. Width follows exactly, and height grows with the pane (for example 100×30 → `25 100`; the exact rows depend on the prompt's height). | Automated test | +| NMSh inside tmux: Ctrl+Z | Suspends the foreground job; `jobs` lists it as suspended. | Automated test | +| NMSh inside tmux: `fg` | Resumes the job. | Manual observation | +| NMSh inside tmux: `less` | Passthrough, then NMSh repaints its composer. | Automated test | +| NMSh inside tmux: `vim` and other fullscreen apps | Same path as `less`. | Expected (physical QA) | +| NMSh inside tmux: bracketed paste | A multi-line `paste-buffer -p` lands in the composer as a paste; nothing runs. | Automated test | +| NMSh inside tmux: mouse | NMSh requests SGR mouse (1000/1003/1006). tmux forwards mouse events only with `set -g mouse on`; otherwise the wheel belongs to tmux (copy mode/scrollback). | Expected (physical QA) | +| NMSh inside tmux: Shift+Enter | NMSh asks for the kitty keyboard protocol, which tmux does not honor. NMSh already decodes both extended encodings tmux can forward (`CSI 27;2;13~`, `CSI 13;2u`). tmux's default `extended-keys off`, and `on` without a modifyOtherKeys request, most likely deliver Shift+Enter as plain Enter, and `set -g extended-keys always` should fix it. `tmux send-keys` cannot produce real extended keys, so this could not be probed. | Expected (physical QA); follow-up 2 | +| NMSh inside tmux: `/copy` | Uses `pbcopy`, which works inside tmux on current macOS. OSC 52 is not used, so tmux `set-clipboard` does not matter. | Expected (physical QA) | +| NMSh inside tmux: tmux server killed | The frontend gets SIGHUP and **detaches**: the live session keeps running in nmshd and appears in `nmsh --sessions`, `/resume` and the startup restore prompt. | Automated test | +| NMSh inside tmux: only its pane closed (`kill-pane`, server still running) | Same: the live session detaches and stays restorable, and the tmux server keeps running. | Automated test | +| NMSh inside tmux: tmux client detached | The pane (and NMSh) keep running, so the live session stays **attached**: other NMSh windows never offer it or take it over. Reattaching tmux shows the same NMSh. | Expected (from tmux semantics; physical QA) | +| tmux inside NMSh | Enters the alternate screen, so NMSh passes the terminal through. It repaints after reattach, and the app's mouse modes are restored on reattach (#131). | Automated test (existing `liveHardening` test) | +| Any alternate-screen program inside NMSh | Gets the full terminal size in passthrough. | Automated test | +| tmux inside NMSh inside tmux | tmux refuses to nest an attached client while `TMUX` is set, as in any shell. Unchanged by NMSh. | Expected | +| NMSh inside GNU screen | Renders; the shell sees `STY`; width follows a resize; Ctrl+Z suspends a job. | Automated test | +| NMSh inside GNU screen: `less` and return | Works. | Manual observation | +| GNU screen inside NMSh | screen switches to the alternate screen, so NMSh passes it through and returns cleanly. | Automated test | +| GNU screen inside NMSh: size | screen 4.00 keeps its window at the size it **started** with. Started from NMSh's composer, that is the composer-reduced height (for example 25 of 30 rows), and it does not grow when NMSh hands over the full terminal. Programs that follow resizes, such as tmux or any alternate-screen app, get the full size. Inside screen, `C-a F` (fit) corrects it. | Manual observation; follow-up 6 | +| Zellij (either direction) | Expected to behave like tmux: a PTY-backed multiplexer that forwards resizes and uses the alternate screen, with its own mouse and keyboard handling. Its kitty keyboard support may differ from tmux. | Expected (unverified) | +| TUIOS / terminal-native splits | Splits in Ghostty, kitty or WezTerm are separate terminals to NMSh; each NMSh is independent. | Expected (unverified) | ## Where persistence overlaps @@ -40,13 +45,20 @@ tmux and NMSh can both keep a shell alive, at different layers: They compose rather than conflict, because NMSh never inspects or depends on tmux state and a session attached in a tmux pane is never offered to, or taken over by, another NMSh window. The one real wrinkle is the environment: -**A live session keeps the environment it was created in.** A session started inside tmux and later reattached from a plain Ghostty window (tested) still has `TERM=tmux-256color`, `TERM_PROGRAM=tmux` and `TMUX` pointing at a tmux server that may be gone. The same applies in reverse: a session started in Ghostty and reattached inside tmux believes it is directly in Ghostty. A shell cannot have its environment replaced from outside, and #127 deliberately gives each session its creating frontend's environment. Effects are usually small (`tmux` inside that shell may refuse to nest; programs may pick capabilities for the wrong terminal), but they are invisible to the user. See follow-up 1. +**A live session keeps the environment it was created in.** A session started inside tmux and later reattached from a plain terminal still has `TERM=tmux-256color`, `TERM_PROGRAM=tmux` and `TMUX` (automated test). `TMUX` remains set after the originating tmux server is killed, and the test confirms that server is gone. The same applies in reverse: a session started in Ghostty and reattached inside tmux believes it is directly in Ghostty. A shell cannot have its environment replaced from outside, and #127 deliberately gives each session its creating frontend's environment. -Startup restore (#197) treats these sessions like any other. Its window launcher detects Ghostty from `GHOSTTY_RESOURCES_DIR`, which tmux inherits from the Ghostty that started it, so "Open all" from inside tmux opens new **Ghostty windows outside tmux** rather than tmux windows (tested at the detection level). That is safe, since each window just runs `nmsh --attach`, but it may not be what a tmux user expects. See follow-up 3. +This is not only cosmetic. Every command started later from that shell inherits the stale values: +- `tmux` may refuse to nest, or `tmux` commands may target a dead server. +- Programs choose terminal capabilities from `TERM`/`TERM_PROGRAM`, so they can pick the wrong terminal's features. +- Scripts that branch on `TMUX` behave as if they were inside tmux. + +What is *not* affected is NMSh's own attaching frontend. It detects its host (for example for startup restore's window launcher) from its **own** environment, not from the shell's, so the reattached shell's stale `TMUX` does not confuse it. See follow-up 1. + +Startup restore (#197) treats these sessions like any other. Its window launcher detects Ghostty from `GHOSTTY_RESOURCES_DIR`, which tmux inherits from the Ghostty that started it, so "Open all" from inside tmux opens new **Ghostty windows outside tmux** rather than tmux windows (automated test at the detection level). That is safe, since each window just runs `nmsh --attach`, but it may not be what a tmux user expects. See follow-up 3. ## Is environment detection needed? -Not for baseline coexistence. Everything in the matrix works with no multiplexer detection, and unknown hosts keep the default behavior. Detection would only be justified for the targeted follow-ups below, and should use environment evidence (`TMUX`, `ZELLIJ`, `STY`, `TERM_PROGRAM`) rather than command names. +Not for baseline coexistence. Everything in the matrix works with no multiplexer detection (screen's fixed starting size aside, which follow-up 6 addresses without detection), and unknown hosts keep the default behavior. Detection would only be justified for the targeted follow-ups below, and should use environment evidence (`TMUX`, `ZELLIJ`, `STY`, `TERM_PROGRAM`) rather than command names. ## Prioritized follow-ups @@ -55,3 +67,4 @@ Not for baseline coexistence. Everything in the matrix works with no multiplexer 3. **"Open all" from inside a multiplexer.** Decide whether startup restore should open tmux windows (`tmux new-window 'nmsh --attach …'`) when `TMUX` is set, or keep opening host windows. This is a product choice for the #197 host abstraction, not a bug. (P2) 4. **Physical Zellij pass.** Run the matrix in Zellij and record results; add Zellij to the `muxInterop` tests if it can be installed where tests run. (P2) 5. **Document recommended tmux settings** (`mouse on`, `extended-keys always`) in the README once 2 is decided. (P3) +6. **Hand known multiplexers the full terminal before they start.** Treat `tmux`, `screen` and `zellij` like the other known fullscreen commands, so passthrough (and the full-size PTY) begins at launch. screen, which keeps its starting size, would then get all rows. (P3) diff --git a/tests/muxInterop.test.ts b/tests/muxInterop.test.ts index 25471bc..1a8a6e4 100644 --- a/tests/muxInterop.test.ts +++ b/tests/muxInterop.test.ts @@ -2,13 +2,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; +import nodePty from 'node-pty'; import {detectTerminalHost} from '../src/host/terminalHost.js'; import {LiveSandbox, until} from './helpers/liveFrontend.js'; import {inForeground, uniqueSleep} from './helpers/processState.js'; /** - * The multiplexer interoperability contract from docs/architecture/multiplexer-interop.md. - * Real tmux and screen are used where installed; otherwise these skip, so CI never depends on them. + * Automated contract coverage for selected tmux/screen interoperability paths + * described in docs/architecture/multiplexer-interop.md (which also marks what + * is only manual or expected). Real tmux and screen are used where installed; + * otherwise these skip, so CI never depends on them. */ const REPO = fileURLToPath(new URL('..', import.meta.url)); @@ -16,6 +19,17 @@ const hasTmux = spawnSync('tmux', ['-V']).status === 0; const hasScreen = spawnSync('screen', ['-v']).status !== null && spawnSync('which', ['screen']).status === 0; const quote = (value: string) => `'${value.replace(/'/gu, `'\\''`)}'`; +/** + * The test runner's environment minus any multiplexer it may itself run in, so + * an outer tmux, screen or Zellij cannot leak into the one under test. Nothing + * else is changed: the tests should see an ordinary user environment. + */ +function cleanEnv(): NodeJS.ProcessEnv { + const env = {...process.env}; + for (const name of Object.keys(env)) if (/^(TMUX|TMUX_PANE|STY|WINDOW|ZELLIJ.*)$/u.test(name)) delete env[name]; + return env; +} + /** NMSh running inside a private tmux server, driven with send-keys and read with capture-pane. */ class TmuxPane { readonly socket = `nmsh-mux-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; @@ -26,7 +40,11 @@ class TmuxPane { `cd ${quote(REPO)} && env ${env} ${quote(process.execPath)} --import=tsx src/index.ts`); } tmux(...args: string[]): string { - return spawnSync('tmux', ['-L', this.socket, '-f', '/dev/null', ...args], {encoding: 'utf8'}).stdout; + return spawnSync('tmux', ['-L', this.socket, '-f', '/dev/null', ...args], {encoding: 'utf8', env: cleanEnv()}).stdout; + } + /** Whether this tmux server still exists. */ + alive(): boolean { + return spawnSync('tmux', ['-L', this.socket, '-f', '/dev/null', 'list-sessions'], {env: cleanEnv()}).status === 0; } screen(): string { return this.tmux('capture-pane', '-p', '-t', 'p'); } keys(...keys: string[]): void { this.tmux('send-keys', '-t', 'p', ...keys); } @@ -40,7 +58,7 @@ class TmuxPane { kill(): void { this.tmux('kill-server'); } } -test('NMSh inside tmux: environment, resize, job control, fullscreen, paste; closing the pane detaches the live session', +test('NMSh inside tmux: environment, resize, job control, fullscreen, paste; killing the tmux server detaches the live session', {skip: hasTmux ? false : 'tmux is not installed'}, async () => { const sandbox = new LiveSandbox(); const pane = new TmuxPane(sandbox); @@ -85,7 +103,7 @@ test('NMSh inside tmux: environment, resize, job control, fullscreen, paste; clo const [live] = await sandbox.sessions(); assert.equal(live?.state, 'attached'); pane.kill(); - await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached after the pane closed'); + await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached after the tmux server was killed'); assert.equal((await sandbox.sessions())[0]!.id, live!.id, 'the same live session, restorable'); } finally { pane.kill(); @@ -103,6 +121,7 @@ test('a live session keeps the environment it was created in: made in tmux, reat const [live] = await sandbox.sessions(); pane.kill(); await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached'); + assert.equal(pane.alive(), false, 'the tmux server that TMUX refers to is gone'); const outside = sandbox.launch(['--attach', live!.id]); await outside.waitFor(/Reattached live session/); @@ -115,17 +134,88 @@ test('a live session keeps the environment it was created in: made in tmux, reat test('GNU screen inside NMSh passes through and returns to the composer', {skip: hasScreen ? false : 'screen is not installed'}, async () => { const sandbox = new LiveSandbox(); + const inner = `nmsh-inner-${process.pid}`; try { const app = sandbox.launch(); await app.waitFor(/❯/); const mark = app.mark; - app.pty.write(`screen -q -S nmsh-inner-${process.pid} zsh -f -c 'echo SCREEN-INNER; sleep 1'\r`); + // Only passthrough and the return are pinned here. screen 4.00 keeps its window at the size it started + // with (the composer-reduced height); see the matrix. NMSh itself resizes the PTY, as a plain + // alternate-screen program shows below. + app.pty.write(`screen -q -S ${inner} zsh -f -c 'echo SCREEN-INNER; sleep 1'\r`); await app.waitFor(/SCREEN-INNER/, mark); await until(() => app.output.indexOf('\u001b[?1049l', mark) !== -1, 20000, 'screen left the alternate screen'); assert.ok(app.output.indexOf('\u001b[?1049h', mark) !== -1, 'screen used the alternate screen, so NMSh passed it through'); await app.waitFor(/❯/, app.output.indexOf('\u001b[?1049l', mark)); await app.run('echo AFTER-SCREEN', /AFTER-SCREEN/); } finally { + // screen detaches instead of exiting when its terminal goes away; end it explicitly. + spawnSync('screen', ['-S', inner, '-X', 'quit'], {env: cleanEnv()}); + await sandbox.dispose(); + } +}); + +test('a program that switches to the alternate screen gets the full terminal in passthrough', async () => { + const sandbox = new LiveSandbox(); + try { + const app = sandbox.launch(); + await app.waitFor(/❯/); + const mark = app.mark; + app.pty.write(`printf '\\e[?1049h'; until [ "$(stty size)" = "30 100" ]; do sleep 0.05; done; echo FULL-SIZE; printf '\\e[?1049l'\r`); + await app.waitFor(/FULL-SIZE/, mark); + await app.run('echo BACK-AT-COMPOSER', /BACK-AT-COMPOSER/); + } finally { + await sandbox.dispose(); + } +}); + +test('closing just the tmux pane (server still running) detaches the live session', {skip: hasTmux ? false : 'tmux is not installed'}, async () => { + const sandbox = new LiveSandbox(); + const pane = new TmuxPane(sandbox); + try { + pane.tmux('new-session', '-d', '-s', 'keep', 'sleep 600'); // keeps the server alive after the pane goes + await pane.waitFor(/❯/, 'composer'); + await pane.run('echo READY', /READY/); + const [live] = await sandbox.sessions(); + assert.equal(live?.state, 'attached'); + pane.tmux('kill-pane', '-t', 'p'); + await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached after kill-pane'); + assert.equal((await sandbox.sessions())[0]!.id, live!.id); + assert.equal(pane.alive(), true, 'only the pane closed; the tmux server is still running'); + } finally { + pane.kill(); + await sandbox.dispose(); + } +}); + +test('NMSh inside GNU screen: renders, sees STY, follows a resize, and suspends a job', {skip: hasScreen ? false : 'screen is not installed'}, async () => { + const sandbox = new LiveSandbox(); + const name = `nmsh-outer-${process.pid}`; + const env = {...cleanEnv(), ...sandbox.env, TERM: 'xterm-256color'} as Record; + for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key]; + const pty = nodePty.spawn('screen', ['-q', '-S', name, 'zsh', '-f', '-c', + `cd ${quote(REPO)} && exec ${quote(process.execPath)} --import=tsx src/index.ts`], {cwd: REPO, cols: 100, rows: 30, env}); + let output = ''; + pty.onData(data => { output += data; }); + const plain = (from: number) => output.slice(from).replace(/\u001b\[[0-9;?>]*[A-Za-z]|\u001b[()][A-Z0-9]|\u001b[=>]/gu, ' '); + const waitFor = (pattern: RegExp, from: number) => until(() => pattern.test(plain(from)), 20000, () => `${pattern}:\n${plain(from).slice(-800)}`); + const run = async (command: string, expect: RegExp) => { const mark = output.length; pty.write(`${command}\r`); await waitFor(expect, mark); }; + try { + await waitFor(/❯/, 0); + await run('echo "STY-${STY:+set}"', /STY-set/); + await run('echo SIZE-$(stty size | tr " " x)', /SIZE-\d+x100/); + pty.resize(120, 40); + await until(async () => { const mark = output.length; pty.write('echo SIZE-$(stty size | tr " " x)\r'); + try { await until(() => /SIZE-\d+x120/u.test(plain(mark)), 2000); return true; } catch { return false; } }, 20000, 'resize reached the shell'); + const duration = uniqueSleep(176); + pty.write(`sleep ${duration}\r`); + await until(() => inForeground(duration), 20000, 'sleep in the foreground'); + pty.write('\u001a'); + await run('jobs', /suspended\s+sleep/); + await run('kill %1; echo JOB-GONE', /JOB-GONE/); + } finally { + pty.kill(); + spawnSync('screen', ['-S', name, '-X', 'quit'], {env: cleanEnv()}); await sandbox.dispose(); } });