fix(tui): say alt+1-9 on the bar, and stop the dot grid when switching

The bar now reads "alt+1-9 switch · F1 back to the codeman dashboard", so the
switch keys are discoverable instead of secret. Shown only when those keys were
actually claimed, the same rule the way-out key follows: a bar naming a key
that does nothing is the bug this series started with.

THE DOT GRID. Switching landed in a pane occupying part of the terminal with
tmux's dot fill everywhere else. It was never a size mismatch — the window was
already the right size. `window-size latest` only resizes a window while a
client is ON it, and the sessions behind the tab strip have none until you
switch, so the resize happened AT the switch: tmux painted the newly-available
area with dots and an idle claude had no reason to redraw into it. Every
switchable session is now pre-sized to the attaching terminal, which moves that
repaint to attach time while the user is still looking at the first session,
and the switch binding restores `window-size latest` on arrival so a mid-attach
terminal resize still follows. Measured: 14 consecutive switches across 7
sessions, zero dot-padded rows, against 1-in-6 before.

⚠️ Known loose end, deliberately not papered over: after a detach the window
SIZE is restored exactly but the window-size MODE can come back as `latest`
rather than `manual`. The restore primitive round-trips correctly in isolation
(manual -> presize -> latest -> restore = manual) and no call site in the TUI
or the server sets `latest` afterwards, so the cause is not yet identified. The
practical effect is nil: the remaining client keeps the window at its own size
and Codeman re-pins `manual` on the browser's next resize.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 74c9879359
commit bc1821661f
3 changed files with 105 additions and 8 deletions
+19 -7
View File
@@ -352,6 +352,8 @@ export function buildAttachBanner(options: {
tabs?: readonly TuiAttachTab[];
/** The attaching terminal's width, so the strip can be kept clear of the hint. */
cols?: number;
/** Alt+1..9 really switch sessions, so the bar may say so. */
switchKeys?: boolean;
}): 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
@@ -367,9 +369,13 @@ export function buildAttachBanner(options: {
// left. tmux truncates a status line that overflows, and what it drops is the
// RIGHT-aligned segment — which is the hint, the one thing on the bar a user
// cannot do without. Every width tested lost it before this budget existed.
// ⚠️ The switch hint appears only when the keys were actually claimed, the
// same rule the way-out key follows. A bar naming a key that does nothing is
// the bug this whole series started with.
const switchHint = options.switchKeys ? 'alt+1-9 switch · ' : '';
const hint = options.oneKey
? ` ${options.oneKey} ${ATTACH_BANNER_MARKER} `
: ` ${chord}${alias} ${ATTACH_BANNER_MARKER} `;
? ` ${switchHint}${options.oneKey} ${ATTACH_BANNER_MARKER} `
: ` ${switchHint}${chord}${alias} ${ATTACH_BANNER_MARKER} `;
const budget = Math.max(0, (options.cols ?? Number.POSITIVE_INFINITY) - hint.length - 1);
// The strip names the session it highlights, so the standalone label is only
// a fallback for when there is no strip to draw (degraded mode has no list).
@@ -385,8 +391,8 @@ export function buildAttachBanner(options: {
// ONLY instruction a user gets during an attach, so it names the simplest
// thing that is known to work, never a menu of ways.
'status-format[0]': options.oneKey
? `#[align=left]${left}#[align=right] #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]`
: `#[align=left]${left}#[align=right] #[bold]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`,
? `#[align=left]${left}#[align=right] ${switchHint}#[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]`
: `#[align=left]${left}#[align=right] ${switchHint}#[bold]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`,
};
}
@@ -531,7 +537,8 @@ export async function beginAttachHandoff(
muxName: string,
label: string,
tabs: readonly TuiAttachTab[] = [],
cols?: number
cols?: number,
rows?: number
): Promise<TuiAttachHandoff> {
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
// Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes.
@@ -580,6 +587,7 @@ export async function beginAttachHandoff(
...(claimed && alias ? { heldAlias: alias } : {}),
...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}),
...(cols ? { cols } : {}),
...(switchKeys.length > 0 ? { switchKeys: true } : {}),
label: ownLabel,
tabs: tabs.map((tab) => ({ ...tab, active: tab.muxName === activeMux })),
});
@@ -610,7 +618,10 @@ export async function beginAttachHandoff(
// at it, which is the same reason the attached one gets it.
const sizing = await client.readWindowSizing(target);
if (sizing) resized.push({ muxName: target, sizing });
await client.followAttachingClient(target);
// Pre-size to this terminal so a switch has nothing left to resize, then
// let the session we are actually opening follow the terminal live.
if (cols && rows && rows > 1) await client.presizeWindow(target, cols, rows - 1);
if (target === muxName) await client.followAttachingClient(target);
}
return {
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
@@ -2261,7 +2272,8 @@ class TuiApp {
muxName,
rowLabel(row.session),
this.attachTabs(row.session.sessionId),
this.currentLayout().cols
this.currentLayout().cols,
this.currentLayout().rows
);
this.detachChordLabel = handoff.chord;
this.screen.leave();