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
+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[];