mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
@@ -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');
|
||||
|
||||
@@ -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]`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user