diff --git a/bin/nmux-linux b/bin/nmux-linux index 1aeddcb..0a5ce41 100755 --- a/bin/nmux-linux +++ b/bin/nmux-linux @@ -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) { @@ -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 @@ -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.) @@ -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); } @@ -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). @@ -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], @@ -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 @@ -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}'], @@ -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). @@ -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; diff --git a/test/task-overlay.test.js b/test/task-overlay.test.js index 2cd19d2..223568f 100644 --- a/test/task-overlay.test.js +++ b/test/task-overlay.test.js @@ -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', () => {