mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +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:
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user