From 737c2ed7f8375963a1e03daad4f6c5ae035571ec Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 12:26:51 +0200 Subject: [PATCH] fix(tui): escape the separator in the switch binding, closing the sizing leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loose end from 777f974, now explained. Sessions came back from a detach on `window-size latest` instead of `manual`, and the restore primitive round-tripped correctly in isolation, so the corruption had to be upstream of it. It was: the snapshot was taken from state this code had already broken. `bindSwitchKey` passed a bare `;` between the two commands it wanted in one binding. That is a command separator to tmux's OWN parser, not an argument: it ended the `bind-key` and executed what followed immediately. So the binding kept only `switch-client`, and `set-window-option ... window-size latest` RAN against every switchable session at attach time — before the sizing snapshot was taken. Every session was therefore snapshotted as `latest` and faithfully restored to `latest`. Proven against real tmux both ways before fixing: a bare `;` leaves the session on `latest` and stores a one-command binding, while `\;` leaves it `manual` and stores both commands. Verified end to end: 7 sessions manual before, 1 latest + 6 manual during the attach (the attached one follows the terminal, the rest are pre-sized), no dot padding on a switch, and all 7 back to 120x40 manual after the detach. This also means the "follow the terminal after switching" half of 777f974 never actually worked — it was never in the binding. --- src/tui/tui-client.ts | 9 ++++++++- test/tui/tui-client.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index be1728ed..9683f3b1 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -1004,6 +1004,13 @@ export class TuiClient { // It is already the right size (see presizeWindow), so `latest` changes // nothing on arrival and costs no repaint — it matters only if the // terminal is resized while sitting in that session. + // + // ⚠️ The separator MUST be an escaped `\;`. A bare `;` is a command + // separator to tmux's own parser, so it ends the `bind-key` and RUNS + // what follows immediately: the binding kept only `switch-client`, and + // every session got `window-size latest` executed on it at attach time. + // That is what left sessions on `latest` after a detach, since the + // snapshot was then taken from already-corrupted state. await this.exec('tmux', [ '-L', this.socket, @@ -1014,7 +1021,7 @@ export class TuiClient { 'switch-client', '-t', target, - ';', + '\\;', 'set-window-option', '-t', target, diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 439814c2..907858d5 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -602,6 +602,32 @@ describe('attach window sizing', () => { }); }); +describe('TuiClient.bindSwitchKey', () => { + it('escapes the command separator, or tmux runs the second command instead of binding it', () => { + // ⚠️ A bare `;` argument is a command separator to tmux's OWN parser: it + // ends the bind-key and executes what follows immediately. That bound only + // `switch-client` and ran `window-size latest` against every session at + // attach time, which is why sessions were left on `latest` after a detach — + // the sizing snapshot was taken from already-corrupted state. Verified + // against real tmux both ways before this test was written. + const calls: string[][] = []; + const exec: TuiExecFile = async (_file, args) => { + calls.push([...args]); + return { stdout: '', stderr: '' }; + }; + const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + return client.bindSwitchKey('M-2', 'codeman-aaaa1111').then(() => { + const bind = calls.find((args) => args.includes('bind-key')); + expect(bind).toBeDefined(); + expect(bind).toContain('\\;'); + expect(bind).not.toContain(';'); + // Both commands have to be in the ONE binding. + expect(bind?.join(' ')).toContain('switch-client -t codeman-aaaa1111'); + expect(bind?.join(' ')).toContain('window-size latest'); + }); + }); +}); + describe('parsePrefixBinding', () => { const REAL = [ 'bind-key -T prefix d detach-client',