From ab4a8686881b982b12d28c05670a0f930d63689f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 00:59:15 +0200 Subject: [PATCH] fix(tui): make the detach chord work when Ctrl is never released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tui/tui-app.ts | 32 +++++++++++++++++++- src/tui/tui-client.ts | 58 +++++++++++++++++++++++++++++++++++++ test/tui/tui-app.test.ts | 29 +++++++++++++++++++ test/tui/tui-client.test.ts | 22 ++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 33f27edd..68112438 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -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 { 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 { + 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. diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 71a75980..4e0a3403 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -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 { + 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 { + 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 { + 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 { let names: string[]; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 9283a078..6ca4a5ed 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -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- convention", () => { expect(nextSessionName('mirofish', [])).toBe('w1-mirofish'); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 01e274cc..c6736eb9 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -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]`;