diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 68112438..900484f1 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -264,6 +264,23 @@ 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 ONE key that leaves an attach, bound in tmux's prefix-less `root` table. + * + * ⚠️ Everything else here is a fallback. tmux's native way out is a chord typed + * in a particular order — press the prefix, LET GO of the modifier, then a + * letter — and three rounds of beta testing died on it: first the bar named the + * wrong letter, then the right letter failed because the modifier was held. + * The instruction itself was the problem ("release Ctrl and THEN d" is, in the + * tester's words, very unclear), so the way out stopped being a chord. + * + * F12 because it is a single keystroke with no modifier to hold or release, and + * because no CLI that runs in these panes wants it: claude, codex, a shell and + * vim all leave it alone, and tmux ships an EMPTY root table apart from mouse + * bindings, so claiming it shadows nothing. + */ +export const ONE_KEY_DETACH = 'F12'; + /** * The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`. * @@ -326,6 +343,8 @@ export function buildAttachBanner(options: { detachKey?: string; /** The held-Ctrl form, when the attach managed to claim it. */ heldAlias?: string; + /** The prefix-less key, when the attach managed to claim it. Preferred over every chord. */ + oneKey?: string; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); // Named on the bar because it is what people actually type: keeping Ctrl held @@ -341,7 +360,12 @@ export function buildAttachBanner(options: { return { status: 'on', 'status-style': 'bg=default,fg=default', - 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, + // One key when we have one, the chord only as a fallback. The bar is the + // ONLY instruction a user gets during an attach, so it names the simplest + // thing that is known to work, never a menu of ways. + 'status-format[0]': options.oneKey + ? `#[align=left] press #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] to get ${ATTACH_BANNER_MARKER}${right}#[default]` + : `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, }; } @@ -440,17 +464,24 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab // 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; + // The one-key way out, in the prefix-less table. Same rule: only if free. + const oneKey = + (await client.readPrefixBinding(ONE_KEY_DETACH, 'root')) === null + ? await client.bindDetachKey(ONE_KEY_DETACH, 'root') + : false; const banner = buildAttachBanner({ ...(prefix ? { prefix } : {}), ...(detachKey ? { detachKey } : {}), ...(claimed && alias ? { heldAlias: alias } : {}), + ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), label, }); const options = await client.readSessionOptions(muxName, Object.keys(banner)); await client.applySessionOptions(muxName, banner); return { - chord: detachChord(prefix, detachKey), + chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey), async restore(): Promise { + if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root'); 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 diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 4e0a3403..679cbf27 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -472,9 +472,10 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu * 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 { +export function parsePrefixBinding(stdout: string, key: string, table: TuiKeyTable = 'prefix'): string | null { + const pattern = new RegExp(`^bind-key\\s+(?:-\\S+\\s+)*?-T\\s+${table}\\s+(\\S+)\\s+(.+)$`); for (const line of stdout.split('\n')) { - const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/.exec(line.trim()); + const match = pattern.exec(line.trim()); if (!match) continue; if (unquoteTmuxValue(match[1] ?? '') !== key) continue; return (match[2] ?? '').trim(); @@ -490,6 +491,13 @@ export function parsePrefixBinding(stdout: string, key: string): string | null { */ export const ATTACH_BANNER_MARKER = 'back to the codeman dashboard'; +/** + * The tmux key tables an attach touches. `root` is the one without a prefix: + * a key bound there is delivered on its own, which is what makes a one-key way + * out possible at all. + */ +export type TuiKeyTable = 'prefix' | 'root'; + /** * The key bound to a bare `detach-client` in `list-keys -T prefix` output, or * null when nothing there detaches. @@ -917,19 +925,26 @@ export class TuiClient { * 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 { + async readPrefixBinding(key: string, table: TuiKeyTable = 'prefix'): Promise { try { - const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']); - return parsePrefixBinding(stdout, key); + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', table]); + return parsePrefixBinding(stdout, key, table); } 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 { + /** + * Claim a key for `detach-client`. Best effort; a failure is not fatal to an + * attach, and the caller advertises the key only if this returned true. + * + * The `root` table is the one that matters for a way OUT: a key bound there + * needs no prefix at all, so leaving a session is one keypress rather than a + * chord typed in the right order. + */ + async bindDetachKey(key: string, table: TuiKeyTable = 'prefix'): Promise { try { - await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'prefix', key, 'detach-client']); + await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', table, key, 'detach-client']); return true; } catch { return false; @@ -937,14 +952,14 @@ export class TuiClient { } /** - * 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. + * Give a 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; + async unbindDetachKey(key: string, table: TuiKeyTable = 'prefix'): Promise { + if ((await this.readPrefixBinding(key, table)) !== 'detach-client') return; try { - await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'prefix', key]); + await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', table, key]); } catch { /* a key we cannot give back is a stray convenience binding, not a failure */ } diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 6ca4a5ed..d66a1ed7 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -19,6 +19,7 @@ import { confirmKillStep, detachChord, heldCtrlAlias, + ONE_KEY_DETACH, nextSessionName, footerKeysFor, formatPrefixKey, @@ -459,6 +460,35 @@ describe('buildListLines', () => { }); }); +describe('the one-key way out', () => { + it('names a single key with no modifier at all', () => { + // The whole point: three beta rounds died on a chord that had to be typed + // in the right order with the modifier released at the right moment. + expect(ONE_KEY_DETACH).toBe('F12'); + expect(ONE_KEY_DETACH).not.toContain('C-'); + expect(ONE_KEY_DETACH).not.toContain('+'); + }); + + it('puts ONE instruction on the bar, not a menu of ways out', () => { + const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F12' }); + const bar = banner['status-format[0]']; + expect(bar).toContain('press #[bold]F12#[nobold] to get back to the codeman dashboard'); + // Even though both fallbacks still work, the bar must not offer them: a bar + // listing three ways to leave is what the tester called way too complicated. + expect(bar).not.toContain('Ctrl+B'); + expect(bar).not.toContain('or Ctrl+D'); + }); + + it('falls back to the chord when the key could not be claimed', () => { + // Never advertise a key we did not get: a bar naming an inert key is the + // original bug, in a new costume. + const bar = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' })['status-format[0]']; + expect(bar).toContain('Ctrl+B then d'); + expect(bar).toContain('(or Ctrl+D)'); + expect(bar).not.toContain('F12'); + }); +}); + 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 diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index c6736eb9..659dbae5 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -621,6 +621,15 @@ describe('parsePrefixBinding', () => { it('is case-sensitive, like tmux itself', () => { expect(parsePrefixBinding('bind-key -T prefix D choose-client', 'd')).toBeNull(); }); + + it('reads the prefix-less root table too, where the one-key exit lives', () => { + const root = ['bind-key -T root MouseDown1Pane select-pane -t =', 'bind-key -T root F12 detach-client'].join('\n'); + expect(parsePrefixBinding(root, 'F12', 'root')).toBe('detach-client'); + // Stock tmux has no F12 there, which is what makes it safe to claim. + expect(parsePrefixBinding('bind-key -T root MouseDown1Pane select-pane -t =', 'F12', 'root')).toBeNull(); + // A prefix binding must not be mistaken for a root one. + expect(parsePrefixBinding('bind-key -T prefix F12 detach-client', 'F12', 'root')).toBeNull(); + }); }); describe('TuiClient.clearLeakedAttachBanners', () => {