Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 114 additions & 55 deletions bin/nmux-linux
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,21 @@ const SESSION = 'nmux-linux';
const SIDEBAR_WIDTH = 26;
const SIDEBAR_MIN_WIDTH = 16;
const SIDEBAR_COMPACT_WIDTH = 20;
const RIGHT_MIN_WIDTH = 16;
const RIGHT_DEFAULT_MIN_WIDTH = 24;
const SUB_MIN_WIDTH = 40;
// Canonical 16:9-derived core layout (issue #21). The sidebar, main, status
// and bash panes have FIXED widths that never track the attached monitor, so
// reconnecting from a different display cannot reflow the main pane or churn
// the core panes. Extra horizontal space on wide monitors is spent on an
// auxiliary sub column instead of widening main.
const MAIN_WIDTH = 120; // fixed main coding pane width
const RIGHT_WIDTH = 40; // fixed status/bash column width
const PANE_BORDERS = 3; // cells the vertical borders consume between columns
// Smallest window the fixed core needs. The window is pinned to at least this
// width (window-size manual) so a narrower client is cropped rather than
// allowed to shrink main; the window only ever GROWS (wider monitor), never
// shrinks, which is what prevents the reflow stall on reconnect.
const CANONICAL_MIN_WIDTH = SIDEBAR_WIDTH + MAIN_WIDTH + RIGHT_WIDTH + PANE_BORDERS;
const CANONICAL_MIN_HEIGHT = 45;
const SCAN_ROOTS = [path.join(HOME, 'projects'), path.join(HOME, 'projects', 'private')];

function clampInt(n, min, max) {
Expand All @@ -44,50 +56,48 @@ function responsiveSidebarWidth(winW, configured = SIDEBAR_WIDTH) {
}

function computeResponsiveLayout(winW, winH, configuredSidebar = SIDEBAR_WIDTH, opts = {}) {
const total = (Number.isFinite(Number(winW)) && Number(winW) > 0)
// Fixed-core layout (issue #21). The sidebar/main/status/bash widths are
// canonical constants — they do NOT scale with the monitor — so a monitor
// swap never reflows the main pane. Any width past the canonical core is
// handed to an auxiliary sub column, so wide displays add capacity there
// rather than by widening (and re-wrapping) main.
const rawTotal = (Number.isFinite(Number(winW)) && Number(winW) > 0)
? Math.floor(Number(winW))
: (Number(configuredSidebar) || SIDEBAR_WIDTH) + 140;
const sidebarWidth = responsiveSidebarWidth(total, configuredSidebar);
: CANONICAL_MIN_WIDTH;
// The window is pinned to at least the canonical width (window-size manual),
// so clamp up: a narrower client is cropped, never allowed to shrink main.
const total = Math.max(CANONICAL_MIN_WIDTH, rawTotal);
const sidebarWidth = clampInt(configuredSidebar || SIDEBAR_WIDTH, 12, 40);
const hasRight = opts.hasRight !== false;
const hasSub = !!opts.hasSub;
const wantSub = !!opts.wantSub || hasSub;
const minMain = total < 80 ? 22 : (total < 120 ? 36 : 50);
const minRight = total < 80 ? RIGHT_MIN_WIDTH : (total < 112 ? 20 : RIGHT_DEFAULT_MIN_WIDTH);
const maxRight = total < 104 ? 24 : (total < 160 ? 32 : 48);
// tmux pane borders consume a few cells between columns. Keeping a small
// reserve prevents a mathematically-full plan from causing tmux to steal
// cells from the sidebar/main during resize.
const available = Math.max(1, total - sidebarWidth - 3);

let rightWidth = hasRight ? clampInt(Math.floor(total / 6), minRight, maxRight) : 0;
if (hasRight && available - rightWidth < minMain) {
rightWidth = Math.max(0, available - minMain);
}

const canFullSub = wantSub
&& isWideAspect(total, Number(winH))
&& available - rightWidth >= minMain + SUB_MIN_WIDTH;
const wantSub = opts.wantSub !== false;

const rightWidth = hasRight ? RIGHT_WIDTH : 0;
const available = Math.max(1, total - sidebarWidth - rightWidth - PANE_BORDERS);
// Main is pinned to its canonical width and never grows past it.
const mainWidth = Math.max(1, Math.min(MAIN_WIDTH, available));
const leftover = available - mainWidth;

let subWidth = 0;
if (canFullSub) {
subWidth = clampInt(Math.floor(total / 3), SUB_MIN_WIDTH, Math.floor(total * 0.34));
} else if (hasSub) {
// Preserve an existing auxiliary pane when moving from a large monitor to
// a small one, but collapse it to a harmless sliver so main+status remain
// usable. We do not kill panes from an automatic resize hook.
subWidth = Math.min(12, Math.max(0, available - rightWidth - minMain));
let subCount = 0;
let canFullSub = false;
if (wantSub && leftover >= SUB_MIN_WIDTH) {
// Absorb ALL leftover width into the sub column so no free cells remain for
// tmux to hand back to main (which would re-wrap it). subCount reports how
// many SUB_MIN_WIDTH slots the extra space affords.
subWidth = leftover;
subCount = Math.max(1, Math.floor(leftover / SUB_MIN_WIDTH));
canFullSub = true;
} else if (hasSub && leftover > 0) {
// Preserve an existing auxiliary pane as a collapsed sliver on a narrower
// window; an automatic resize never kills panes.
subWidth = Math.min(12, leftover);
if (subWidth < 6) subWidth = 0;
subCount = subWidth > 0 ? 1 : 0;
}

if (available - rightWidth - subWidth < minMain) {
subWidth = Math.max(0, available - rightWidth - minMain);
}
if (available - rightWidth - subWidth < minMain) {
rightWidth = Math.max(0, available - subWidth - minMain);
}
if (hasRight) rightWidth = Math.max(1, rightWidth);
const mainWidth = Math.max(1, available - rightWidth - subWidth);
const mode = total < 104 ? 'small' : (canFullSub ? 'wide' : 'medium');
return { mode, total, sidebarWidth, mainWidth, rightWidth, subWidth, canFullSub };
const mode = mainWidth < MAIN_WIDTH ? 'small' : (canFullSub ? 'wide' : 'medium');
return { mode, total, sidebarWidth, mainWidth, rightWidth, subWidth, subCount, canFullSub };
}

// Resolve own install location from argv[1] so a non-default --prefix
Expand Down Expand Up @@ -331,13 +341,15 @@ function installBindings(session) {
`set-option -t ${session} status off`,
`set-option -t ${session} base-index 1`,
`set-option -t ${session} mouse on`,
// Size each window to the CURRENTLY-attached client, not the largest
// client that ever viewed it. nmux-linux is a single-user frontend;
// with the default `largest`, moving from an ultrawide back to a 16:9
// monitor left windows stuck at the ultrawide width, so the layout
// kept showing the ultrawide-only sub pane. `latest` makes the layout
// track the monitor you are actually on.
`set-option -t ${session} window-size latest`,
// Fix the window size manually instead of tracking the attached client
// (issue #21). With `latest`/`largest`, attaching from a different monitor
// resizes the window, which makes tmux proportionally re-wrap the main
// pane — expensive on long agent sessions — and churns the sub-pane layout.
// With `manual`, tmux never auto-resizes the window; pinWindowSizes() sets
// and only ever GROWS each window (max of canonical, current, client), so a
// reconnect from a smaller/different screen leaves the fixed core untouched
// and the main pane is never reflowed.
`set-option -t ${session} window-size manual`,

// Keep per-pane border titles, but hide the one above the sidebar pane.
// (Per-window pane-border-format set in scrubWindowStatus.)
Expand Down Expand Up @@ -486,6 +498,9 @@ function buildWindow(cfg, project, isFirst) {
tmux(['set-window-option', '-t', target, 'window-status-separator', ''], { allowFail: true });
tmux(['set-window-option', '-t', target, 'pane-border-status', 'top'], { allowFail: true });

// Pin to the canonical size before splitting so buildLayout reads a stable,
// fixed window width (window-size is manual, issue #21).
pinWindowSizes(cfg.session);
buildLayout(cfg, target, project, cwd, shell);
}

Expand All @@ -500,7 +515,7 @@ function buildLayout(cfg, target, project, cwd, shell) {
const winH = parseInt(whStr, 10);
const plan = computeResponsiveLayout(winW, winH, cfg.sidebarWidth || SIDEBAR_WIDTH, {
hasRight: true,
wantSub: isWideAspect(winW, winH),
wantSub: true,
});

// Split LEFT for sidebar (fixed cells).
Expand Down Expand Up @@ -619,7 +634,7 @@ function relayoutCurrent() {
const [wwR, whR] = winInfoR.split('\t').map(n => parseInt(n, 10));
const planR = computeResponsiveLayout(wwR, whR, cfg.sidebarWidth || SIDEBAR_WIDTH, {
hasRight: true,
wantSub: isWideAspect(wwR, whR),
wantSub: true,
});
if (planR.subWidth > 0) {
tmux(['split-window', '-t', main.pid, '-h', '-l', String(planR.subWidth), '-c', cwd, shell],
Expand Down Expand Up @@ -1040,7 +1055,7 @@ function convergeResponsiveWindowLayout(cfg, info) {
const plan = computeResponsiveLayout(info.winW, info.winH, cfg.sidebarWidth || SIDEBAR_WIDTH, {
hasRight: !!(status || bash),
hasSub: !!sub,
wantSub: isWideAspect(info.winW, info.winH),
wantSub: true,
});

// Two passes converge better because tmux redistributes cells from
Expand All @@ -1054,13 +1069,57 @@ function convergeResponsiveWindowLayout(cfg, info) {
}
}

// Widest client currently attached to the session (0/0 when detached).
function widestAttachedClient(session) {
const out = tmux(['list-clients', '-t', session, '-F', '#{client_width}\t#{client_height}'],
{ allowFail: true });
let w = 0, h = 0;
for (const line of out.split('\n')) {
if (!line) continue;
const [wStr, hStr] = line.split('\t');
const cw = parseInt(wStr, 10);
const ch = parseInt(hStr, 10);
if (Number.isFinite(cw)) w = Math.max(w, cw);
if (Number.isFinite(ch)) h = Math.max(h, ch);
}
return { w, h };
}

// Pin every window to a fixed canonical size (issue #21). window-size is
// `manual`, so tmux never resizes windows on attach/detach. We resize each
// window to the MAX of (canonical minimum, its current size, the widest
// attached client) — i.e. the window only ever grows. It never shrinks, so a
// reconnect from a smaller/different monitor does not resize (and therefore
// does not reflow) the main pane. A wider monitor grows the window once and the
// extra width lands in the sub column, never in main.
function pinWindowSizes(session) {
const client = widestAttachedClient(session);
const out = tmux(['list-windows', '-t', session, '-F',
'#{window_id}\t#{window_width}\t#{window_height}'], { allowFail: true });
for (const line of out.split('\n')) {
if (!line) continue;
const [wid, wwStr, whStr] = line.split('\t');
if (!wid) continue;
const curW = parseInt(wwStr, 10) || 0;
const curH = parseInt(whStr, 10) || 0;
const targetW = Math.max(CANONICAL_MIN_WIDTH, curW, client.w || 0);
const targetH = Math.max(CANONICAL_MIN_HEIGHT, curH, client.h || 0);
if (targetW !== curW || targetH !== curH) {
tmux(['resize-window', '-t', wid, '-x', String(targetW), '-y', String(targetH)],
{ allowFail: true });
}
}
}

function fixLayout() {
// tmux scales panes proportionally on resize; converge every nmux-linux
// window back to a monitor-size-aware layout. Small clients get a narrower
// sidebar and collapsed auxiliary pane; wide clients keep the larger
// proportions. Automatic resize never kills panes.
// Converge every nmux-linux window back to the fixed canonical layout
// (issue #21). First pin the window size (only ever grows, never shrinks) so
// a monitor swap cannot reflow main; then re-pin the fixed core pane widths
// and route any spare width into the sub column. Automatic resize never kills
// core panes.
if (process.env.NMUX_LINUX_HOOK_SESSION && process.env.NMUX_LINUX_HOOK_SESSION !== SESSION) return;
if (!sessionExists(SESSION)) return;
pinWindowSizes(SESSION);
const cfg = loadConfig() || initConfig();
const out = tmux(['list-panes', '-s', '-t', SESSION, '-F',
'#{window_id}\t#{window_index}\t#{pane_id}\t#{pane_title}\t#{pane_pid}\t#{pane_width}\t#{window_width}\t#{window_height}\t#{@nmuxlinux_project}\t#{pane_current_path}\t#{pane_current_command}'],
Expand Down Expand Up @@ -1178,7 +1237,7 @@ function ensureSubPane(cfg, info) {
const plan = computeResponsiveLayout(info.winW, info.winH, cfg.sidebarWidth || SIDEBAR_WIDTH, {
hasRight: true,
hasSub: info.panes.some(p => paneRoleOf(p.title) === 'sub'),
wantSub: isWideAspect(info.winW, info.winH),
wantSub: true,
});
// All sub panes currently in the window (normally 0 or 1, but a racing
// resize hook can briefly leave duplicates — handle them defensively).
Expand Down Expand Up @@ -1351,7 +1410,7 @@ function resizeCols() {
const plan = computeResponsiveLayout(info.winW, info.winH, cfg.sidebarWidth || SIDEBAR_WIDTH, {
hasRight: true,
hasSub: !!sub,
wantSub: isWideAspect(info.winW, info.winH),
wantSub: true,
});
const sw = plan.sidebarWidth;
const wide = plan.canFullSub;
Expand Down
49 changes: 25 additions & 24 deletions test/task-overlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,45 +61,46 @@ test('overlay text clipping is deterministic', () => {
assert.equal(overlay.clipDisplayText('abc', 4), 'abc');
});

test('responsive layout uses compact stable widths on small screens', () => {
test('core panes keep fixed canonical widths on small clients (no shrink/reflow)', () => {
// A client narrower than the canonical core is cropped, never allowed to
// shrink the fixed core (issue #21): sidebar/main/status stay constant.
const p = overlay.computeResponsiveLayout(80, 24, 26, { hasRight: true, wantSub: true });
assert.equal(p.mode, 'small');
assert.equal(p.sidebarWidth, 20);
assert.equal(p.sidebarWidth, 26);
assert.equal(p.rightWidth, 40);
assert.equal(p.mainWidth, 120);
assert.equal(p.subWidth, 0);
assert.ok(p.mainWidth >= 36);
assert.ok(p.rightWidth >= 16);
});

test('responsive layout keeps full auxiliary column on wide screens', () => {
test('main width is identical across monitor sizes (no reflow on reattach)', () => {
const narrow = overlay.computeResponsiveLayout(190, 50, 26, { hasRight: true });
const wide = overlay.computeResponsiveLayout(320, 80, 26, { hasRight: true });
assert.equal(narrow.mainWidth, wide.mainWidth);
assert.equal(narrow.sidebarWidth, wide.sidebarWidth);
assert.equal(narrow.rightWidth, wide.rightWidth);
});

test('wide-monitor width goes to the sub column, not main', () => {
const p = overlay.computeResponsiveLayout(260, 45, 26, { hasRight: true, wantSub: true });
assert.equal(p.mode, 'wide');
assert.equal(p.sidebarWidth, 26);
assert.equal(p.mainWidth, 120); // main stays canonical, never widened
assert.ok(p.subWidth >= 40);
assert.ok(p.mainWidth > p.subWidth);
assert.ok(p.rightWidth >= 24);
assert.ok(p.subCount >= 1);
assert.equal(p.rightWidth, 40);
});

test('16:9 monitors stay 3-column (no sub pane); only ultrawide gets one', () => {
// A normal 16:9 monitor is ~3.4-3.5:1 in terminal cells even at high
// resolution where it spans many columns. It must NOT get a sub pane.
assert.equal(overlay.isWideAspect(179, 52), false); // 3.44:1, 16:9
assert.equal(overlay.isWideAspect(240, 67), false); // 3.58:1, high-res 16:9
test('normal monitor (no spare width) stays 3-column with no sub pane', () => {
const wide169 = overlay.computeResponsiveLayout(179, 52, 26, { hasRight: true, wantSub: true });
assert.equal(wide169.subWidth, 0, '16:9 must stay 3-column');
assert.equal(wide169.subWidth, 0, 'no spare width means 3-column');
assert.notEqual(wide169.mode, 'wide');

// Genuine ultrawide (>=4.0) earns the extra far-right pane.
assert.equal(overlay.isWideAspect(260, 45), true); // 5.78:1, 21:9
const ultra = overlay.computeResponsiveLayout(260, 45, 26, { hasRight: true, wantSub: true });
assert.equal(ultra.mode, 'wide');
assert.ok(ultra.subWidth >= 40, `expected an extra pane, got subWidth=${ultra.subWidth}`);
assert.equal(wide169.mainWidth, 120);
});

test('responsive layout collapses an existing sub pane instead of killing small-screen usability', () => {
const p = overlay.computeResponsiveLayout(92, 28, 26, { hasRight: true, hasSub: true });
assert.equal(p.sidebarWidth, 20);
test('an existing sub pane collapses to a sliver when width is tight', () => {
const p = overlay.computeResponsiveLayout(200, 45, 26, { hasRight: true, hasSub: true });
assert.equal(p.mainWidth, 120);
assert.equal(p.sidebarWidth, 26);
assert.ok(p.subWidth <= 12);
assert.ok(p.mainWidth >= 36);
});

test('sanitizeTitle drops kitty-graphics APC blobs that leak into pane_title', () => {
Expand Down