diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index e2dd1ef8..d263d764 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -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 { 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 { 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(); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 4302ad0c..be1728ed 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -883,6 +883,43 @@ export class TuiClient { } } + /** + * Size a window to the terminal that is about to look at it, NOW. + * + * ⚠️ `window-size latest` only resizes a window while a client is actually on + * it. 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 + * its dot fill, and an idle claude had no reason to redraw into it, leaving a + * pane in the corner of a dotted screen (reported from the beta, with a + * screenshot). Pre-sizing moves that repaint to attach time, while the user + * is still looking at the first session. + * + * `resize-window` with an explicit size implies `window-size manual`, which + * is what we want: the size is already right when the switch lands, so tmux + * has nothing to change and nothing to repaint. The switch binding puts + * `latest` back so a mid-attach terminal resize still follows. + */ + async presizeWindow(muxName: string, cols: number, rows: number): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return false; + if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0) return false; + try { + await this.exec('tmux', [ + '-L', + this.socket, + 'resize-window', + '-t', + muxName, + '-x', + String(cols), + '-y', + String(rows), + ]); + return true; + } catch { + return false; + } + } + /** * tmux's prefix key for a session (`C-b` unless the user's config says * otherwise), or null when tmux cannot say. Session-level first, then global: @@ -963,7 +1000,27 @@ export class TuiClient { async bindSwitchKey(key: string, target: string): Promise { 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]); + // Two commands: go there, then let that window follow this terminal again. + // It is already the right size (see presizeWindow), so `latest` changes + // nothing on arrival and costs no repaint — it matters only if the + // terminal is resized while sitting in that session. + await this.exec('tmux', [ + '-L', + this.socket, + 'bind-key', + '-T', + 'root', + key, + 'switch-client', + '-t', + target, + ';', + 'set-window-option', + '-t', + target, + 'window-size', + 'latest', + ]); return true; } catch { return false; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index c76bf5ad..bdc34735 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -468,6 +468,34 @@ describe('the one-key way out', () => { expect(ONE_KEY_DETACH).not.toContain('+'); }); + it('advertises alt+1-9 only once those keys were really claimed', () => { + // Same rule as the way-out key: never name a key that does nothing. That + // is the bug this whole series started with. + const withSwitch = buildAttachBanner({ oneKey: 'F1', switchKeys: true, cols: 150 })['status-format[0]']; + expect(withSwitch).toContain('alt+1-9 switch'); + expect(withSwitch).toContain('back to the codeman dashboard'); + expect(buildAttachBanner({ oneKey: 'F1', cols: 150 })['status-format[0]']).not.toContain('alt+1-9'); + }); + + it('still fits the strip once the switch hint has taken its space', () => { + for (const cols of [80, 100, 120, 190]) { + const bar = buildAttachBanner({ + oneKey: 'F1', + switchKeys: true, + cols, + tabs: Array.from({ length: 12 }, (_, i) => ({ + index: i + 1, + label: `w${i + 1}-session-name`, + active: i === 1, + })), + })['status-format[0]']; + const visible = bar.replace(/#\[[^\]]*\]/g, ''); + expect({ cols, fits: visible.length <= cols }).toEqual({ cols, fits: true }); + expect(visible).toContain('alt+1-9 switch'); + expect(visible).toContain('back to the codeman dashboard'); + } + }); + it('puts ONE instruction on the bar, not a menu of ways out', () => { const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F1' }); const bar = banner['status-format[0]'];