fix(tui): advertise the key that actually detaches, and stop tmux painting it green

Two things the attach status bar got wrong, both found in a beta test.

The bar read `Ctrl+B D`. tmux key tables are case-sensitive: lowercase `d` is
`detach-client`, capital `D` is `choose-client`. Pressing what the bar said
opened a client chooser and left the tester attached, with the way out on
screen and inert. The key is now READ from `list-keys -T prefix` the same way
the prefix already was, rather than hardcoded, so a rebound tmux is followed
too and the label cannot drift from the binding again. It never goes through
formatPrefixKey(), which uppercases.

The bar also rendered as a full-width bright green slab. Only `status-format[0]`
was styled, so tmux's stock `status-style` (`bg=green,fg=black`) stayed
underneath it and won; `#[reverse]` on top could not undo it. `status-style` is
now set explicitly to `bg=default,fg=default` and snapshotted/restored with the
rest, so the bar sits on the terminal's own background and reads as a hint
line.

Tests pin both: that the chord ends in lowercase `d` and never ` D`, that a
rebound key prints verbatim, that `status-style` is part of the banner, and
that parseDetachKey() picks `d` out of verbatim tmux 3.4 `list-keys` output
while ignoring `detach-client -a`/`-P`, which act on other clients.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 0919f9da62
commit aa487f13ce
4 changed files with 150 additions and 19 deletions
+36
View File
@@ -467,6 +467,28 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu
return options;
}
/**
* The key bound to a bare `detach-client` in `list-keys -T prefix` output, or
* null when nothing there detaches.
*
* ⚠️ Read rather than assumed because the two candidates differ only by case:
* tmux ships `d` as `detach-client` and `D` as `choose-client`, and advertising
* the wrong one leaves a tester attached with the way out on screen. Bindings
* that pass ARGUMENTS to `detach-client` (`-a`, `-P`) are skipped: those act on
* other clients or kill the pane's process, which is not what the bar promises.
* A single-character binding wins over a named key, since that is what a status
* line can print literally.
*/
export function parseDetachKey(stdout: string): string | null {
const candidates: string[] = [];
for (const line of stdout.split('\n')) {
const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+detach-client\s*$/.exec(line.trim());
const key = match?.[1];
if (key) candidates.push(unquoteTmuxValue(key));
}
return candidates.find((key) => key.length === 1) ?? candidates[0] ?? null;
}
/** `status-format[0]` → `status-format`; a plain option name → null. */
export function arrayOptionBase(key: string): string | null {
const match = /^([^[\]]+)\[\d+\]$/.exec(key);
@@ -853,6 +875,20 @@ export class TuiClient {
return null;
}
/**
* The key that detaches, straight from tmux's own key table. Key tables are
* server-global, so unlike the prefix this takes no session. A failure is
* null and the caller falls back to tmux's stock `d`.
*/
async readDetachKey(): Promise<string | null> {
try {
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']);
return parseDetachKey(stdout);
} catch {
return null;
}
}
/** Snapshot the session-level options an attach is about to overwrite. */
async readSessionOptions(muxName: string, keys: readonly string[]): Promise<TuiSessionOptions | null> {
if (!MUX_NAME_PATTERN.test(muxName)) return null;