fix(tui): keep the way out on the bar, and make Alt+1..9 actually switch

Four faults, all reported at once, and three of them were mine from the last
two commits.

THE HINT VANISHED. Two independent causes. First, a leaked F1 binding: an
attach whose TUI was killed leaves `F1 -> detach-client` in tmux's root table,
and the claim treated "already bound" as someone else's key, so every later
attach fell back to advertising the tmux chord — the bar stopped saying F1
while F1 still worked. A key already bound to `detach-client` now counts as
ours. Second, width: tmux truncates a status line that overflows and drops the
RIGHT-aligned segment, which is the hint. The strip now gets a budget measured
from the terminal's width minus the hint, and it drops tabs from the far end
until it fits. ⚠️ Measured on VISIBLE columns, not format bytes: `#[reverse]`
costs zero columns, and counting it made a strip that "fitted" still truncate
the hint at 80, 100, 120 and 176 columns on a real terminal.

ALT+N DID NOT SWITCH. On the dashboard, a bare digit meant jump AND ATTACH, and
a terminal sends Alt+N as ESC then N: when those land in separate reads —
routine over SSH — the chord decodes as Escape plus a bare digit, so "switch to
tab 2" threw the user into tab 2's pane. A digit now SELECTS, matching what
Alt+N means in the web UI; Enter is how you go in. Inside a pane the keys never
reached the TUI at all, since tmux owns the terminal, so the attach now binds
Alt+1..9 in tmux's root table to `switch-client` — the strip is usable rather
than decorative. ⚠️ The bar is applied to every session the strip can reach,
each highlighting its own tab: with it on the attached session only, switching
landed the user in a pane with no strip and no way out on screen.

⚠️ The leaked-state sweep was missing `status-position`, so it removed the
marker and left the position behind — and with no marker the leftover no longer
matched, making it permanently unsweepable. Found by diffing every session's
options after a detach.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent aec6516638
commit 9b29666e03
5 changed files with 185 additions and 33 deletions
+36
View File
@@ -951,6 +951,36 @@ export class TuiClient {
}
}
/**
* Bind a prefix-less key to switch this client to another session, so the tab
* strip on the attach bar is not just a picture: Alt+1..9 moves between
* sessions from INSIDE a pane, the way it does in the web UI.
*
* `switch-client` rather than detach-then-attach: it keeps the terminal, so
* the move is instant and the TUI stays blocked in its `spawnSync` exactly as
* before, still holding the restore it owes.
*/
async bindSwitchKey(key: string, target: string): Promise<boolean> {
if (!MUX_NAME_PATTERN.test(target)) return false;
try {
await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'root', key, 'switch-client', '-t', target]);
return true;
} catch {
return false;
}
}
/** Give back a switch key, but only while it still points at a session. */
async unbindSwitchKey(key: string): Promise<void> {
const bound = await this.readPrefixBinding(key, 'root');
if (!bound || !bound.startsWith('switch-client')) return;
try {
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'root', key]);
} catch {
/* a stray switch binding is harmless next to failing an attach */
}
}
/**
* Give a 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
@@ -1103,6 +1133,12 @@ export class TuiClient {
// unsetting one index leaves an empty array, which renders as a blank bar.
await this.setOption(['-u', '-t', name, 'status-format']);
await this.setOption(['-u', '-t', name, 'status-style']);
// ⚠️ `status-position` MUST be swept with the rest. It was missing here,
// so a sweep cleaned the marker and left the position behind — and with
// the marker gone the leftover no longer matched, which made it
// permanently unsweepable. Every option the banner writes has to be
// undone by the same pass that recognises it.
await this.setOption(['-u', '-t', name, 'status-position']);
await this.setOption(['-t', name, 'status', 'off']);
cleared += 1;
}