mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(tmux): keep browser keys from switching a tab onto another session (#669)
Squash of PR #669. Browser terminals could be moved onto another session by tmux's prefix keys: Ctrl+B then ), (, s or w switched the tab's tmux client to a different session while the tab kept the original's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, and codeman tui and scripts/tmux-manager.sh get F1 as their way out instead of Ctrl+B d. Merge-time fixes by the maintainer: on an empty prefix table the unbind-key that fails no longer skips binding send-prefix; a key tmux lists escaped (prefix C-\ shows as C-\\) compares equal, so the reset does not rerun on every create; and reconcileSessions() reduces the table at boot when the socket has live sessions, so sessions that survive an upgrade are covered too. Co-authored-by: christianhaberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
620e7728ec
commit
6eaaa960ff
14 files changed
+775
-28
No files matched your search
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @fileoverview scripts/tmux-manager.sh attaches with a way back out.
|
||||
*
|
||||
* Codeman keeps the prefix table on its socket down to `send-prefix`
|
||||
* (src/tmux-prefix-table.ts), so tmux's `Ctrl+B d` no longer detaches there. The
|
||||
* script's attach claims F1 in the root table for the length of the attach instead,
|
||||
* and leaves a binding the user owns alone.
|
||||
*
|
||||
* Runs the real script against a REAL tmux server on a private throwaway socket
|
||||
* (never Codeman's own) with a throwaway HOME, through a pty like a terminal would.
|
||||
*
|
||||
* Port: N/A (no server)
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
const hasTools = (() => {
|
||||
try {
|
||||
execSync('tmux -V && jq --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe.skipIf(!hasTools)('scripts/tmux-manager.sh attach (real tmux, private socket)', () => {
|
||||
const socket = `codeman-script-test-${process.pid}`;
|
||||
const tmux = `tmux -L ${socket} -f /dev/null`;
|
||||
const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const rootKeys = () => run(`${tmux} list-keys -T root`);
|
||||
const clients = () => run(`${tmux} list-clients -F '#{client_session}'`).trim();
|
||||
let home = '';
|
||||
|
||||
async function waitFor(check: () => boolean, ms = 4000): Promise<boolean> {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (check()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return check();
|
||||
}
|
||||
|
||||
/** `tmux-manager.sh attach 1` in a pty; resolves its output once it exits. */
|
||||
function attach(): { script: pty.IPty; exited: Promise<string> } {
|
||||
const env = { ...process.env, HOME: home, CODEMAN_TMUX_SOCKET: socket } as Record<string, string>;
|
||||
delete env.TMUX; // the suite may itself run inside tmux
|
||||
const script = pty.spawn('bash', ['scripts/tmux-manager.sh', 'attach', '1'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
});
|
||||
let output = '';
|
||||
script.onData((data) => (output += data));
|
||||
const exited = new Promise<string>((resolve) => script.onExit(() => resolve(output)));
|
||||
return { script, exited };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-script-home-'));
|
||||
mkdirSync(join(home, '.codeman'));
|
||||
writeFileSync(join(home, '.codeman', 'mux-sessions.json'), JSON.stringify([{ name: 'alpha', muxName: 'alpha' }]));
|
||||
run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat'`);
|
||||
// What Codeman leaves on its socket: nothing but send-prefix in the prefix table.
|
||||
run(`${tmux} unbind-key -a -T prefix`);
|
||||
run(`${tmux} bind-key -T prefix C-b send-prefix`);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
let socketPath = '';
|
||||
try {
|
||||
socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim();
|
||||
run(`${tmux} kill-server`); // our own throwaway socket only
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
|
||||
if (home) rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('F1 detaches back out of the attach, and the claim is given back afterwards', async () => {
|
||||
expect(rootKeys()).not.toMatch(/-T root F1\s/);
|
||||
const { script, exited } = attach();
|
||||
try {
|
||||
expect(await waitFor(() => clients() === 'alpha')).toBe(true);
|
||||
expect(rootKeys()).toMatch(/-T root F1\s+detach-client/);
|
||||
|
||||
script.write('\x1bOP'); // F1
|
||||
const output = await exited;
|
||||
|
||||
expect(output).toContain('F1 to detach');
|
||||
expect(clients()).toBe('');
|
||||
expect(rootKeys()).not.toMatch(/-T root F1\s/);
|
||||
expect(run(`${tmux} has-session -t alpha && echo alive`).trim()).toBe('alive');
|
||||
} finally {
|
||||
script.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an F1 binding of the user's own alone and says how to detach instead", async () => {
|
||||
run(`${tmux} bind-key -T root F1 display-message mine`);
|
||||
const { script, exited } = attach();
|
||||
try {
|
||||
expect(await waitFor(() => clients() === 'alpha')).toBe(true);
|
||||
expect(rootKeys()).toMatch(/-T root F1\s+display-message mine/);
|
||||
|
||||
run(`${tmux} detach-client -s alpha`);
|
||||
const output = await exited;
|
||||
|
||||
expect(output).toContain('detach-client -s alpha');
|
||||
expect(output).not.toContain('F1 to detach');
|
||||
expect(rootKeys()).toMatch(/-T root F1\s+display-message mine/);
|
||||
} finally {
|
||||
script.kill();
|
||||
run(`${tmux} unbind-key -T root F1`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+89
-10
@@ -727,20 +727,99 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tmux launch cwd hardening', () => {
|
||||
async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> {
|
||||
const originalVitest = process.env.VITEST;
|
||||
vi.resetModules();
|
||||
async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> {
|
||||
const originalVitest = process.env.VITEST;
|
||||
vi.resetModules();
|
||||
delete process.env.VITEST;
|
||||
const module = await import('../src/tmux-manager.js');
|
||||
if (originalVitest === undefined) {
|
||||
delete process.env.VITEST;
|
||||
const module = await import('../src/tmux-manager.js');
|
||||
if (originalVitest === undefined) {
|
||||
delete process.env.VITEST;
|
||||
} else {
|
||||
process.env.VITEST = originalVitest;
|
||||
} else {
|
||||
process.env.VITEST = originalVitest;
|
||||
}
|
||||
return module.TmuxManager;
|
||||
}
|
||||
|
||||
describe('prefix table on the codeman socket', () => {
|
||||
it("reduces the socket's prefix table to send-prefix before the agent starts", async () => {
|
||||
// Browser keystrokes reach the attach client; with the stock table Ctrl+B ) or s
|
||||
// would move a tab onto another session (the behavior itself: tmux-prefix-table.test.ts).
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
|
||||
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n';
|
||||
// Not tmux's stock C-b, so a hardcoded prefix cannot pass for the one read from tmux.
|
||||
if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n';
|
||||
if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) {
|
||||
return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.createSession({
|
||||
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
|
||||
workingDir: '/project',
|
||||
mode: 'shell',
|
||||
});
|
||||
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
|
||||
expect(commands).toContain(`tmux -L 'codeman' unbind-key -a -T prefix`);
|
||||
const bind = commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`);
|
||||
expect(bind).toBeGreaterThan(commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`));
|
||||
expect(bind).toBeLessThan(commands.findIndex((c) => c.includes(' respawn-pane ')));
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
return module.TmuxManager;
|
||||
});
|
||||
|
||||
/** execSync for a boot-time reconcile: `panes` is what `list-panes -a` prints. */
|
||||
function mockReconcileTmux(panes: string): void {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
|
||||
if (typeof cmd === 'string' && cmd.includes(' list-panes -a ')) return panes;
|
||||
if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n';
|
||||
if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) {
|
||||
return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
it('reduces it at boot too, for sessions that survived an upgrade', async () => {
|
||||
mockReconcileTmux('codeman-abc12345|4242|0||\n');
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.reconcileSessions();
|
||||
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
|
||||
const unbind = commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`);
|
||||
expect(unbind).toBeGreaterThan(-1);
|
||||
expect(commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`)).toBeGreaterThan(unbind);
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the key tables alone at boot when the socket lists no pane', async () => {
|
||||
// On an empty socket `list-keys` would start a throwaway tmux server.
|
||||
mockReconcileTmux('');
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.reconcileSessions();
|
||||
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
|
||||
expect(commands.some((c) => c.includes(' list-panes -a '))).toBe(true);
|
||||
expect(commands.filter((c) => /show-options -gv prefix|list-keys|unbind-key|bind-key/.test(c))).toEqual([]);
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tmux launch cwd hardening', () => {
|
||||
beforeEach(() => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === 'string' && cmd.includes('which tmux')) {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @fileoverview The tmux prefix table on Codeman's socket holds only
|
||||
* `<prefix> send-prefix`, so keys typed into a browser terminal cannot move its
|
||||
* attach client onto another session, without taking `codeman tui`'s way out away.
|
||||
*
|
||||
* Two layers here (the createSession wiring is in tmux-manager.test.ts):
|
||||
* - the decision (`prefixTableNeedsReset`) on real `list-keys` shapes,
|
||||
* - the effect against a REAL tmux server on a private throwaway socket (never
|
||||
* Codeman's own), driven through a real attach client in a pty, the way a
|
||||
* browser tab drives one.
|
||||
*
|
||||
* Port: N/A (no server)
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { rmSync } from 'node:fs';
|
||||
import * as pty from 'node-pty';
|
||||
import { prefixTableNeedsReset, restrictTmuxPrefixTable } from '../src/tmux-prefix-table.js';
|
||||
|
||||
/** A trimmed copy of tmux 3.4's stock prefix table, as `list-keys -T prefix` prints it. */
|
||||
const STOCK = [
|
||||
'bind-key -T prefix C-b send-prefix',
|
||||
'bind-key -T prefix C-o rotate-window',
|
||||
'bind-key -T prefix C-z suspend-client',
|
||||
'bind-key -T prefix ( switch-client -p',
|
||||
'bind-key -T prefix ) switch-client -n',
|
||||
'bind-key -T prefix L switch-client -l',
|
||||
'bind-key -T prefix d detach-client',
|
||||
'bind-key -T prefix s choose-tree -Zs',
|
||||
'bind-key -T prefix w choose-tree -Zw',
|
||||
'bind-key -r -T prefix Up select-pane -U',
|
||||
].join('\n');
|
||||
|
||||
const RESTRICTED = 'bind-key -T prefix C-b send-prefix\n';
|
||||
|
||||
describe('prefixTableNeedsReset', () => {
|
||||
it("resets tmux's stock table (switch-client, choose-tree, detach-client on d)", () => {
|
||||
expect(prefixTableNeedsReset(STOCK)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a table that holds only C-b send-prefix alone', () => {
|
||||
expect(prefixTableNeedsReset(RESTRICTED)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a codeman tui attach's held-Ctrl detach alias alone", () => {
|
||||
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix C-d detach-client\n`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('resets when a single foreign binding is back, or the stock detach key d', () => {
|
||||
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix s choose-tree -Zs\n`)).toBe(
|
||||
true
|
||||
);
|
||||
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix d detach-client\n`)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('resets an empty table or one without send-prefix, and output it cannot read', () => {
|
||||
expect(prefixTableNeedsReset('')).toBe(true);
|
||||
expect(prefixTableNeedsReset('bind-key -T prefix C-d detach-client\n')).toBe(true);
|
||||
expect(prefixTableNeedsReset('something unexpected\n')).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the socket's configured prefix instead of assuming C-b", () => {
|
||||
const ctrlA = 'bind-key -T prefix C-a send-prefix\n';
|
||||
expect(prefixTableNeedsReset(ctrlA, 'C-a')).toBe(false);
|
||||
// C-b send-prefix under a C-a prefix is a leftover, not the binding we want.
|
||||
expect(prefixTableNeedsReset(RESTRICTED, 'C-a')).toBe(true);
|
||||
expect(prefixTableNeedsReset('', 'None')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a key tmux lists backslash-escaped as the key itself', () => {
|
||||
// `prefix C-\` is printed as is by show-options, but listed as `C-\\`.
|
||||
expect(prefixTableNeedsReset('bind-key -T prefix C-\\\\ send-prefix\n', 'C-\\')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrictTmuxPrefixTable (recorded commands)', () => {
|
||||
/** A runner that answers from `replies` (a thrown Error stands for a failing command) and records every call. */
|
||||
function fakeTmux(replies: Record<string, string | Error>): { run: (cmd: string) => string; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const run = (cmd: string): string => {
|
||||
calls.push(cmd);
|
||||
const sub = cmd.replace(/^tmux -L 'codeman' /, '');
|
||||
const reply = replies[sub] ?? '';
|
||||
if (reply instanceof Error) throw reply;
|
||||
return reply;
|
||||
};
|
||||
return { run, calls };
|
||||
}
|
||||
|
||||
it('still binds send-prefix when the table is empty and unbind-key -a fails on it', () => {
|
||||
// tmux 3.4 on an empty prefix table: both commands exit 1 with "table prefix doesn't exist".
|
||||
const missing = new Error("table prefix doesn't exist");
|
||||
const tmux = fakeTmux({
|
||||
'show-options -gv prefix': 'C-b\n',
|
||||
'list-keys -T prefix': missing,
|
||||
'unbind-key -a -T prefix': missing,
|
||||
});
|
||||
expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(true);
|
||||
expect(tmux.calls).toContain(`tmux -L 'codeman' bind-key -T prefix 'C-b' send-prefix`);
|
||||
});
|
||||
|
||||
it('does not reset again when the listed prefix is only escaped differently', () => {
|
||||
const tmux = fakeTmux({
|
||||
'show-options -gv prefix': 'C-\\\n',
|
||||
'list-keys -T prefix': 'bind-key -T prefix C-\\\\ send-prefix\n',
|
||||
});
|
||||
expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(false);
|
||||
expect(tmux.calls.filter((c) => c.includes('bind-key'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const hasTmux = (() => {
|
||||
try {
|
||||
execSync('tmux -V', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe.skipIf(!hasTmux)('restrictTmuxPrefixTable against a real tmux (private socket)', () => {
|
||||
// Throwaway server, never Codeman's socket; -f /dev/null keeps the user's config out.
|
||||
const socket = `codeman-prefix-test-${process.pid}`;
|
||||
const tmux = `tmux -L ${socket} -f /dev/null`;
|
||||
const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const keys = (table: string) => run(`${tmux} list-keys -T ${table}`);
|
||||
let client: pty.IPty | null = null;
|
||||
|
||||
/** The session the attach client is looking at right now. */
|
||||
const clientSession = () => run(`${tmux} list-clients -F '#{client_session}'`).trim();
|
||||
|
||||
async function waitFor(check: () => boolean, ms = 3000): Promise<boolean> {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (check()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return check();
|
||||
}
|
||||
|
||||
/** Types keys into the attach client, then gives tmux time to act on them. */
|
||||
async function type(keysToSend: string): Promise<void> {
|
||||
for (const ch of keysToSend) {
|
||||
client!.write(ch);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// `cat -v` prints a received Ctrl+B as ^B, which is how send-prefix shows up.
|
||||
run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat -v'`);
|
||||
run(`${tmux} new-session -d -s beta -x 80 -y 24 'cat -v'`);
|
||||
const env = { ...process.env } as Record<string, string>;
|
||||
delete env.TMUX; // the suite may itself run inside tmux
|
||||
// What a browser tab is: a pty running `tmux attach-session` (tmux-manager getAttachArgs).
|
||||
client = pty.spawn('tmux', ['-L', socket, 'attach-session', '-t', 'alpha'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env,
|
||||
});
|
||||
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
client?.kill();
|
||||
let socketPath = '';
|
||||
try {
|
||||
socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim();
|
||||
run(`${tmux} kill-server`); // our own throwaway socket only
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// tmux leaves the socket file behind; remove ours (and only ours).
|
||||
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
|
||||
});
|
||||
|
||||
it("with tmux's stock table, Ctrl+B ) moves the attach client onto another session", async () => {
|
||||
// The bug, reproduced so the next test means something.
|
||||
await type('\x02)');
|
||||
expect(await waitFor(() => clientSession() === 'beta')).toBe(true);
|
||||
run(`${tmux} switch-client -c $(${tmux} list-clients -F '#{client_name}') -t alpha`);
|
||||
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
|
||||
});
|
||||
|
||||
it('after the reset, Ctrl+B ) and Ctrl+B s leave the client where it is, Ctrl+B Ctrl+B reaches the pane', async () => {
|
||||
const rootBefore = keys('root');
|
||||
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
||||
|
||||
const prefix = keys('prefix')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
expect(prefix).toHaveLength(1);
|
||||
expect(prefix[0]).toMatch(/-T prefix C-b\s+send-prefix$/);
|
||||
expect(keys('root')).toBe(rootBefore);
|
||||
expect(run(`${tmux} show-options -gv prefix`).trim()).toBe('C-b');
|
||||
|
||||
await type('\x02)');
|
||||
await type('\x02s');
|
||||
expect(clientSession()).toBe('alpha');
|
||||
expect(run(`${tmux} display-message -p -t alpha '#{pane_in_mode}'`).trim()).toBe('0');
|
||||
|
||||
await type('\x02\x02');
|
||||
expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^B'))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a codeman tui attach's way out when another session is created mid-attach", () => {
|
||||
// What beginAttachHandoff claims: C-d in the prefix table, F1 and M-1 in root.
|
||||
run(`${tmux} bind-key -T prefix C-d detach-client`);
|
||||
run(`${tmux} bind-key -T root F1 detach-client`);
|
||||
run(`${tmux} bind-key -T root M-1 switch-client -t alpha`);
|
||||
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(false);
|
||||
|
||||
expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/);
|
||||
expect(keys('root')).toMatch(/-T root F1\s+detach-client/);
|
||||
expect(keys('root')).toMatch(/-T root M-1\s+switch-client -t alpha/);
|
||||
});
|
||||
|
||||
it("gives a running attach's held-Ctrl way out back when the reset clears a stock table", () => {
|
||||
// A tmux server whose table is still stock while a codeman tui attach holds C-d:
|
||||
// `unbind-key -a` takes C-d along with the rest.
|
||||
try {
|
||||
run(`${tmux} bind-key -T prefix d detach-client`);
|
||||
run(`${tmux} bind-key -T prefix ')' switch-client -n`);
|
||||
run(`${tmux} bind-key -T prefix C-d detach-client`);
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
||||
const prefix = keys('prefix')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
expect(prefix).toHaveLength(2);
|
||||
expect(keys('prefix')).toMatch(/-T prefix C-b\s+send-prefix/);
|
||||
expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/);
|
||||
} finally {
|
||||
run(`${tmux} unbind-key -T prefix C-d`);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds send-prefix to the configured prefix when the socket uses another one', () => {
|
||||
run(`${tmux} set-option -g prefix C-a`);
|
||||
try {
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
||||
const prefix = keys('prefix')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-a\s+send-prefix$/)]);
|
||||
} finally {
|
||||
run(`${tmux} set-option -g prefix C-b`);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds send-prefix on a server whose prefix table is empty', () => {
|
||||
// On an empty table tmux fails both `list-keys -T prefix` and `unbind-key -a -T prefix`.
|
||||
run(`${tmux} unbind-key -a -T prefix`);
|
||||
expect(() => run(`${tmux} unbind-key -a -T prefix`)).toThrow();
|
||||
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
||||
const prefix = keys('prefix')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-b\s+send-prefix$/)]);
|
||||
});
|
||||
|
||||
it('resets once, not on every call, for a prefix tmux lists escaped (C-\\)', () => {
|
||||
run(`${tmux} set-option -g prefix 'C-\\'`);
|
||||
try {
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
||||
expect(keys('prefix')).toMatch(/-T prefix C-\\\\\s+send-prefix/);
|
||||
expect(restrictTmuxPrefixTable(tmux, run)).toBe(false);
|
||||
} finally {
|
||||
run(`${tmux} set-option -g prefix C-b`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
applyLiveMetrics,
|
||||
applyMuxNames,
|
||||
beginAttachHandoff,
|
||||
buildAttachBanner,
|
||||
buildListLines,
|
||||
confirmKillStep,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
tmuxSocketFromEnv,
|
||||
} from '../../src/tui/tui-app.js';
|
||||
import { createTuiModel, stateSince } from '../../src/tui/tui-model.js';
|
||||
import type { TuiClient } from '../../src/tui/tui-client.js';
|
||||
import { glyphsFor } from '../../src/tui/tui-render.js';
|
||||
import type { TuiLiveSessionMetrics, TuiTmuxSession } from '../../src/tui/tui-client.js';
|
||||
import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js';
|
||||
@@ -596,6 +598,40 @@ describe('the held-Ctrl detach alias', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the way out when F1 is taken, on a socket with no detach key', () => {
|
||||
// Codeman keeps only `<prefix> send-prefix` in its socket's prefix table
|
||||
// (src/tmux-prefix-table.ts), so tmux's stock `Ctrl+B then d` does nothing there.
|
||||
it('names the held-Ctrl chord on the bar instead of the stock d', () => {
|
||||
const bar = buildAttachBanner({ prefix: 'C-b', heldAlias: 'C-d' })['status-format[0]'];
|
||||
expect(bar).toContain('#[bold]Ctrl+B then Ctrl+D#[nobold]');
|
||||
expect(bar).not.toContain('then d');
|
||||
});
|
||||
|
||||
it('hands the same chord back from the attach handoff, for the help overlay and the hint line', async () => {
|
||||
const free = new Set(['C-d', 'M-1']);
|
||||
const client = {
|
||||
readPrefixKey: async () => 'C-b',
|
||||
readDetachKey: async () => null,
|
||||
readPrefixBinding: async (key: string) => (free.has(key) ? null : 'display-message mine'),
|
||||
bindDetachKey: async () => true,
|
||||
unbindDetachKey: async () => true,
|
||||
bindSwitchKey: async () => true,
|
||||
unbindSwitchKey: async () => true,
|
||||
readSessionOptions: async () => null,
|
||||
applySessionOptions: async () => true,
|
||||
restoreSessionOptions: async () => true,
|
||||
readWindowSizing: async () => null,
|
||||
presizeWindow: async () => true,
|
||||
followAttachingClient: async () => true,
|
||||
restoreWindowSizing: async () => true,
|
||||
} as unknown as TuiClient;
|
||||
|
||||
const handoff = await beginAttachHandoff(client, 'codeman-abc12345', 'w1-case');
|
||||
|
||||
expect(handoff.chord).toBe('Ctrl+B then Ctrl+D');
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming a session the TUI starts', () => {
|
||||
it("follows the web UI's w<n>-<case> convention", () => {
|
||||
expect(nextSessionName('mirofish', [])).toBe('w1-mirofish');
|
||||
@@ -737,8 +773,11 @@ describe('the way out of an attach', () => {
|
||||
const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A then d' });
|
||||
const detach = keys.find(([key]) => key === 'Ctrl+A then d');
|
||||
expect(detach?.[1]).toContain('detach');
|
||||
// Degraded mode still attaches, so it still needs the way out.
|
||||
expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B then d');
|
||||
// Degraded mode still attaches, so it still needs the way out. Before the first
|
||||
// attach that is F1: Codeman's socket has no `d` in its prefix table to name.
|
||||
const before = helpKeysFor(GLYPHS, { server: false }).map(([key]) => key);
|
||||
expect(before).toContain(ONE_KEY_DETACH);
|
||||
expect(before).not.toContain('Ctrl+B then d');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -558,6 +558,12 @@ describe('codeman tui (under a pty)', () => {
|
||||
term.write('?');
|
||||
await waitFor(() => frameLines(output).some((line) => line.includes('Keys')), 'the help overlay');
|
||||
expect(frameLines(output).join('\n')).toContain('kill (y to confirm)');
|
||||
// Nothing has been attached yet, so this is the dashboard's starting label:
|
||||
// F1, what an attach claims, not tmux's stock chord, which Codeman's socket
|
||||
// does not bind (src/tmux-prefix-table.ts).
|
||||
const detachLine = frameLines(output).find((line) => line.includes('detach from an attached session'));
|
||||
expect(detachLine).toContain('F1');
|
||||
expect(detachLine).not.toContain('Ctrl+B');
|
||||
|
||||
term.write('\u001b');
|
||||
await waitFor(() => !frameLines(output).some((line) => line.includes('Keys')), 'escape to close the overlay');
|
||||
|
||||
Reference in new issue
Block a user