feat(tui): leave an attach with ONE key, F12, and no modifier

Three beta rounds died on tmux's native way out, and the last one died on the
instruction rather than the mechanism: "press Ctrl+B, release Ctrl, then d" is,
in the tester's words, very unclear, and holding the modifier through both keys
silently does nothing.

So the way out stops being a chord. The attach claims F12 in tmux's prefix-less
`root` table for its own duration, and the bar reads "press F12 to get back to
the codeman dashboard" — one keystroke, nothing to hold, nothing to release,
no order to get right. F12 because stock tmux ships an empty root table apart
from mouse bindings, and none of the CLIs that run in these panes want the key.

⚠️ The bar names the one key ONLY when the claim succeeded, and falls back to
the chord wording otherwise. A bar advertising a key that does nothing is the
bug this whole series started with, and it must not come back in a new costume.
Same claim rules as the prefix alias: taken only when tmux reports the key
unbound, given back only while it still means `detach-client`.

The chord and the held-Ctrl alias both keep working; they are simply no longer
what the user is told to press.
This commit is contained in:
Codeman maintainer
2026-08-20 01:04:08 +02:00
parent 027e40f962
commit 20cd47e2c3
4 changed files with 101 additions and 16 deletions
+30
View File
@@ -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
+9
View File
@@ -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', () => {