fix(tui): make the detach chord work when Ctrl is never released

Reported three times as "Ctrl+B and d is still not working", on a build whose
bar already named the right key. Measured against a live pane: of the three
ways a person types this, only one worked.

  Ctrl+B, release Ctrl, then d   detaches
  Ctrl+B then Ctrl+D (held)      nothing happens
  Ctrl+B then Shift+D            nothing happens

Holding Ctrl through both keys sends 0x02 then 0x04, and tmux ships `C-d`
unbound in the prefix table, so the keystroke is swallowed in silence and the
attach looks frozen. That is not a user error worth documenting around: holding
the modifier is how most people type a two-key chord.

The attach now claims the held-Ctrl form of whatever key detaches (`d` → `C-d`)
for its own duration and gives it back on restore, and the bar advertises it
only once the claim succeeded, so it can never name a key that does nothing.
⚠️ The key is claimed ONLY when tmux reports it unbound, and released only
while it still means `detach-client`, so a binding of the user's own is never
shadowed or removed. The alias is deliberately excluded from the leaked-state
sweep: key tables are server-global, so the sweep cannot tell a leak from a
second TUI's live claim, and a stray `C-d`→detach is harmless either way.

Ruled out along the way, with evidence rather than assumption: the encoding.
tmux negotiates no extended-key mode upstream on attach (no kitty CSI-u, no
modifyOtherKeys, no DECSET 2017), so Ctrl+B does arrive as a plain 0x02 even
from a Claude pane, which has its own keyboard protocol.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 35b2c1baa5
commit ab4a868688
4 changed files with 140 additions and 1 deletions
+31 -1
View File
@@ -264,6 +264,24 @@ export function formatPrefixKey(prefix: string | undefined): string {
/** tmux's stock `detach-client` binding, used when `list-keys` cannot be read. */
export const DEFAULT_DETACH_KEY = 'd';
/**
* The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`.
*
* ⚠️ This is the single most reported way the way-out fails. "Ctrl+B then d"
* gets typed as one held chord, the terminal sends 0x02 then 0x04, and tmux
* leaves `C-d` unbound in the prefix table, so absolutely nothing happens and
* the user concludes the app is frozen (measured on the beta: three separate
* reports, and both variants verified inert against a live pane).
*
* Null when there is no sensible alias: a key that is already a chord, or not a
* single letter, has no "held Ctrl" form worth claiming.
*/
export function heldCtrlAlias(key: string): string | null {
const trimmed = (key ?? '').trim();
if (!/^[A-Za-z]$/.test(trimmed)) return null;
return `C-${trimmed.toLowerCase()}`;
}
/**
* The chord that ends an attach: the prefix, then the key bound to
* `detach-client`.
@@ -306,8 +324,14 @@ export function buildAttachBanner(options: {
prefix?: string;
label?: string;
detachKey?: string;
/** The held-Ctrl form, when the attach managed to claim it. */
heldAlias?: string;
}): Record<string, string> {
const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey));
// Named on the bar because it is what people actually type: keeping Ctrl held
// is the common way to press this, and the bar has to say that it works.
const held = options.heldAlias ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
const alias = held ? ` (or ${held})` : '';
const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX));
// ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]`
// owns the whole line, which is what removes tmux's window list (`0:bash*`)
@@ -317,7 +341,7 @@ export function buildAttachBanner(options: {
return {
status: 'on',
'status-style': 'bg=default,fg=default',
'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`,
'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`,
};
}
@@ -412,9 +436,14 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab
// and the caller is blocked in `spawnSync`.
const sizing = await client.readWindowSizing(muxName);
await client.followAttachingClient(muxName);
// Claimed only when tmux has nothing there: an attach must never shadow a
// binding the user put in their own config.
const alias = heldCtrlAlias(detachKey ?? DEFAULT_DETACH_KEY);
const claimed = alias && (await client.readPrefixBinding(alias)) === null ? await client.bindDetachKey(alias) : false;
const banner = buildAttachBanner({
...(prefix ? { prefix } : {}),
...(detachKey ? { detachKey } : {}),
...(claimed && alias ? { heldAlias: alias } : {}),
label,
});
const options = await client.readSessionOptions(muxName, Object.keys(banner));
@@ -422,6 +451,7 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab
return {
chord: detachChord(prefix, detachKey),
async restore(): Promise<void> {
if (claimed && alias) await client.unbindDetachKey(alias);
// Options first, then the size: dropping the status bar gives its row
// back to the pane, and the resize is what re-pins the browser's
// authority over the window.
+58
View File
@@ -467,6 +467,21 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu
return options;
}
/**
* What `list-keys -T prefix` says a single key is bound to, or null when the
* key appears nowhere in the table. The command is returned verbatim, so a
* caller can insist on an exact match rather than a prefix of one.
*/
export function parsePrefixBinding(stdout: string, key: string): string | null {
for (const line of stdout.split('\n')) {
const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/.exec(line.trim());
if (!match) continue;
if (unquoteTmuxValue(match[1] ?? '') !== key) continue;
return (match[2] ?? '').trim();
}
return null;
}
/**
* The phrase the attach status bar carries. It doubles as the marker that tells
* OUR bar apart from a user's own when sweeping one that leaked, so
@@ -897,6 +912,44 @@ export class TuiClient {
}
}
/**
* The command bound to a key in tmux's prefix table, or null when the key is
* free. Used to check a key is unbound BEFORE claiming it, so the attach can
* never shadow a binding the user relies on.
*/
async readPrefixBinding(key: string): Promise<string | null> {
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']);
return parsePrefixBinding(stdout, key);
} catch {
return null;
}
}
/** Claim a prefix key for `detach-client`. Best effort; a failure is not fatal to an attach. */
async bindDetachKey(key: string): Promise<boolean> {
try {
await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'prefix', key, 'detach-client']);
return true;
} catch {
return false;
}
}
/**
* Give a prefix key back, but ONLY while it still means `detach-client`.
* Anything else there is the user's, arrived after we bound ours, and must
* not be removed.
*/
async unbindDetachKey(key: string): Promise<void> {
if ((await this.readPrefixBinding(key)) !== 'detach-client') return;
try {
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'prefix', key]);
} catch {
/* a key we cannot give back is a stray convenience binding, not a failure */
}
}
/**
* Is this session's active pane DEAD — the process it ran has exited and tmux
* is holding the corpse on screen?
@@ -997,6 +1050,11 @@ export class TuiClient {
* Only a bar carrying our own marker is touched, and it is put back the way
* Codeman creates its panes (`status off`), which is the only state this bar
* is ever applied over.
*
* The held-Ctrl detach alias is deliberately NOT swept here. It is invisible,
* harmless and arguably useful if it leaks, while sweeping it would rip the
* key out from under a SECOND TUI that is mid-attach right now — key tables
* are server-global, so this method cannot tell a leak from a live claim.
*/
async clearLeakedAttachBanners(): Promise<number> {
let names: string[];
+29
View File
@@ -18,6 +18,7 @@ import {
confirmAccepts,
confirmKillStep,
detachChord,
heldCtrlAlias,
nextSessionName,
footerKeysFor,
formatPrefixKey,
@@ -458,6 +459,34 @@ describe('buildListLines', () => {
});
});
describe('the held-Ctrl detach alias', () => {
it('names the key a user produces when they never let go of Ctrl', () => {
// The failure this exists for: "Ctrl+B then d" typed as one held chord
// sends 0x02 then 0x04, and tmux leaves C-d unbound, so nothing happens.
expect(heldCtrlAlias('d')).toBe('C-d');
});
it('lowercases, so a rebound uppercase key still yields the chord it produces', () => {
expect(heldCtrlAlias('Q')).toBe('C-q');
});
it('has no alias for a key with no held-Ctrl form', () => {
expect(heldCtrlAlias('F1')).toBeNull();
expect(heldCtrlAlias('C-d')).toBeNull();
expect(heldCtrlAlias('')).toBeNull();
expect(heldCtrlAlias('1')).toBeNull();
});
it('advertises the alias on the bar only once it has been claimed', () => {
const withAlias = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' });
expect(withAlias['status-format[0]']).toContain('Ctrl+B then d');
expect(withAlias['status-format[0]']).toContain('(or Ctrl+D)');
// Not claimed (the key was already bound to something of the user's) means
// not advertised: a bar naming a key that does nothing is the original bug.
expect(buildAttachBanner({ prefix: 'C-b', detachKey: 'd' })['status-format[0]']).not.toContain('or Ctrl');
});
});
describe('naming a session the TUI starts', () => {
it("follows the web UI's w<n>-<case> convention", () => {
expect(nextSessionName('mirofish', [])).toBe('w1-mirofish');
+22
View File
@@ -23,6 +23,7 @@ import {
arrayOptionBase,
parseSessionOptions,
parseDetachKey,
parsePrefixBinding,
ATTACH_BANNER_MARKER,
parseTmuxSessionList,
parseWindowSizing,
@@ -601,6 +602,27 @@ describe('attach window sizing', () => {
});
});
describe('parsePrefixBinding', () => {
const REAL = [
'bind-key -T prefix d detach-client',
'bind-key -T prefix D choose-client -Z',
'bind-key -T prefix C-b send-prefix',
].join('\n');
it('reports what a key is bound to, verbatim', () => {
expect(parsePrefixBinding(REAL, 'd')).toBe('detach-client');
expect(parsePrefixBinding(REAL, 'D')).toBe('choose-client -Z');
});
it('is null for a key nothing claims, which is what makes it safe to claim', () => {
expect(parsePrefixBinding(REAL, 'C-d')).toBeNull();
});
it('is case-sensitive, like tmux itself', () => {
expect(parsePrefixBinding('bind-key -T prefix D choose-client', 'd')).toBeNull();
});
});
describe('TuiClient.clearLeakedAttachBanners', () => {
const OURS = `#[align=left] press #[bold]Ctrl+B then d#[nobold] to detach, ${ATTACH_BANNER_MARKER} #[default]`;