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:
authored and Codeman maintainer committed 2026-10-22 19:23:17 +02:00
1 parent 620e7728ec
commit 6eaaa960ff
14 files changed
+775 -28

No files matched your search

+123
View File
@@ -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
View File
@@ -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')) {
+280
View File
@@ -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`);
}
});
});
+41 -2
View File
@@ -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');
});
});
+6
View File
@@ -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');