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

+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');