mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
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:
+19
-7
@@ -352,6 +352,8 @@ export function buildAttachBanner(options: {
|
|||||||
tabs?: readonly TuiAttachTab[];
|
tabs?: readonly TuiAttachTab[];
|
||||||
/** The attaching terminal's width, so the strip can be kept clear of the hint. */
|
/** The attaching terminal's width, so the strip can be kept clear of the hint. */
|
||||||
cols?: number;
|
cols?: number;
|
||||||
|
/** Alt+1..9 really switch sessions, so the bar may say so. */
|
||||||
|
switchKeys?: boolean;
|
||||||
}): Record<string, string> {
|
}): Record<string, string> {
|
||||||
const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey));
|
const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey));
|
||||||
// Named on the bar because it is what people actually type: keeping Ctrl held
|
// 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
|
// 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
|
// 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.
|
// 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
|
const hint = options.oneKey
|
||||||
? ` ${options.oneKey} ${ATTACH_BANNER_MARKER} `
|
? ` ${switchHint}${options.oneKey} ${ATTACH_BANNER_MARKER} `
|
||||||
: ` ${chord}${alias} ${ATTACH_BANNER_MARKER} `;
|
: ` ${switchHint}${chord}${alias} ${ATTACH_BANNER_MARKER} `;
|
||||||
const budget = Math.max(0, (options.cols ?? Number.POSITIVE_INFINITY) - hint.length - 1);
|
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
|
// 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).
|
// 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
|
// ONLY instruction a user gets during an attach, so it names the simplest
|
||||||
// thing that is known to work, never a menu of ways.
|
// thing that is known to work, never a menu of ways.
|
||||||
'status-format[0]': options.oneKey
|
'status-format[0]': options.oneKey
|
||||||
? `#[align=left]${left}#[align=right] #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]`
|
? `#[align=left]${left}#[align=right] ${switchHint}#[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]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +537,8 @@ export async function beginAttachHandoff(
|
|||||||
muxName: string,
|
muxName: string,
|
||||||
label: string,
|
label: string,
|
||||||
tabs: readonly TuiAttachTab[] = [],
|
tabs: readonly TuiAttachTab[] = [],
|
||||||
cols?: number
|
cols?: number,
|
||||||
|
rows?: number
|
||||||
): Promise<TuiAttachHandoff> {
|
): Promise<TuiAttachHandoff> {
|
||||||
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
|
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
|
||||||
// Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes.
|
// 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 } : {}),
|
...(claimed && alias ? { heldAlias: alias } : {}),
|
||||||
...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}),
|
...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}),
|
||||||
...(cols ? { cols } : {}),
|
...(cols ? { cols } : {}),
|
||||||
|
...(switchKeys.length > 0 ? { switchKeys: true } : {}),
|
||||||
label: ownLabel,
|
label: ownLabel,
|
||||||
tabs: tabs.map((tab) => ({ ...tab, active: tab.muxName === activeMux })),
|
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.
|
// at it, which is the same reason the attached one gets it.
|
||||||
const sizing = await client.readWindowSizing(target);
|
const sizing = await client.readWindowSizing(target);
|
||||||
if (sizing) resized.push({ muxName: target, sizing });
|
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 {
|
return {
|
||||||
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
|
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
|
||||||
@@ -2261,7 +2272,8 @@ class TuiApp {
|
|||||||
muxName,
|
muxName,
|
||||||
rowLabel(row.session),
|
rowLabel(row.session),
|
||||||
this.attachTabs(row.session.sessionId),
|
this.attachTabs(row.session.sessionId),
|
||||||
this.currentLayout().cols
|
this.currentLayout().cols,
|
||||||
|
this.currentLayout().rows
|
||||||
);
|
);
|
||||||
this.detachChordLabel = handoff.chord;
|
this.detachChordLabel = handoff.chord;
|
||||||
this.screen.leave();
|
this.screen.leave();
|
||||||
|
|||||||
+58
-1
@@ -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<boolean> {
|
||||||
|
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
|
* 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:
|
* 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<boolean> {
|
async bindSwitchKey(key: string, target: string): Promise<boolean> {
|
||||||
if (!MUX_NAME_PATTERN.test(target)) return false;
|
if (!MUX_NAME_PATTERN.test(target)) return false;
|
||||||
try {
|
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;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -468,6 +468,34 @@ describe('the one-key way out', () => {
|
|||||||
expect(ONE_KEY_DETACH).not.toContain('+');
|
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', () => {
|
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 banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F1' });
|
||||||
const bar = banner['status-format[0]'];
|
const bar = banner['status-format[0]'];
|
||||||
|
|||||||
Reference in New Issue
Block a user