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..8941634 --- /dev/null +++ b/docs/architecture/multiplexer-interop.md @@ -0,0 +1,70 @@ +# 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 + +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: + +- **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 | Evidence | +| --- | --- | --- | +| 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 + +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 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. + +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 (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 + +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) +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/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); diff --git a/tests/muxInterop.test.ts b/tests/muxInterop.test.ts new file mode 100644 index 0000000..1a8a6e4 --- /dev/null +++ b/tests/muxInterop.test.ts @@ -0,0 +1,233 @@ +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'; + +/** + * 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)); +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)}`; + 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', 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); } + 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; 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); + 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 tmux server was killed'); + 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'); + 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/); + 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(); + const inner = `nmsh-inner-${process.pid}`; + try { + const app = sandbox.launch(); + await app.waitFor(/❯/); + const mark = app.mark; + // 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(); + } +}); + +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); +});