mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29: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:
+31
-1
@@ -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<string, string> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user