fix(ui): stop interactive-panel flicker on Windows resize and 46-49 row band (#86)

The earlier #86 fix corrected the off-by-one width and the ANSI clear
sequence, but the height math and resize handling were still wrong, so the
panel kept flickering on a band of terminal sizes.

- interactive-panel.js: derive the full-layout reserved-row budget from the
  real banner line count (loadTextBanner().length) plus a named
  FIXED_CHROME_LINES constant instead of the magic literal 47, so layout
  budgeting tracks the banner asset automatically. Raise the non-win32
  compact-layout threshold to getReservedRows(false) + MIN_FULL_COMMAND_ROWS
  (= 49) so heights 46-49 use the compact header and never overflow.
- interactive-panel.js: add a debounced process.stdout 'resize' listener
  (registered on start, removed on stop, no leak) so a resized terminal
  repaints at the new width even on Windows where the pulse timer is off.
- interactive-panel.js: stop the banner pulse from hammering a full-screen
  clear+redraw 8x/second; slow the cadence and skip the repaint when the
  color phase did not change. Drop the redundant second full clear after the
  startup reveal animation.
- cli-theme.js: reserve one trailing column on every platform (not just
  win32) so auto-wrap terminals (libvte/tmux/SSH) do not tear the border;
  export getTextBannerLineCount/loadTextBanner for budget derivation.
- tests: add windows-panel-overflow.test.js (registered in run-all-tests.js)
  asserting no line-budget overflow across heights for win32 + posix,
  reserved == banner + FIXED_CHROME_LINES, columns-1 width on both platforms,
  and resize-listener add/remove around start/stop. Adapt two now-stale
  assertions in cli-interactive-panel.test.js to the corrected math.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
signerless 2026-06-19 21:00:20 +02:00
parent 35805303ff
commit 520f1b50ff
5 changed files with 284 additions and 20 deletions

View file

@ -82,17 +82,16 @@ function sleep(ms) {
}
function getSafeTerminalWidth(columns = process.stdout.columns, platform = process.platform) {
void platform;
const value = Number(columns);
if (!Number.isFinite(value) || value <= 0) return null;
const width = Math.floor(value);
// Windows terminals commonly wrap when output writes exactly `columns` cells.
// Keep the canonical banner intact while rendering one cell inside the edge.
if (platform === 'win32') {
return Math.max(20, width - 1);
}
return Math.max(20, width);
// Many terminals (Windows consoles, libvte, tmux, SSH) auto-wrap when output
// writes exactly `columns` cells, which tears the persistent panel border on
// resize/pulse. The canonical banner is centered/clipped, so reserving one
// trailing cell on every platform is invisible while avoiding the wrap.
return Math.max(20, width - 1);
}
function getTerminalClearSequence(platform = process.platform) {
@ -361,6 +360,11 @@ function loadTextBanner(sourceFile) {
}
}
function getTextBannerLineCount(sourceFile) {
const lines = loadTextBanner(sourceFile);
return Array.isArray(lines) ? lines.length : 0;
}
function drawTextBanner(lines, options = {}) {
const palette = getTextBannerPalette(options);
const colorPhase = Number.isFinite(options.colorPhase)
@ -774,6 +778,8 @@ module.exports = {
getTerminalClearSequence,
makeFrames,
drawFrame,
resolveHasDarkBackground
resolveHasDarkBackground,
loadTextBanner,
getTextBannerLineCount
}
};

View file

@ -9,10 +9,39 @@ const {
renderPersistentBanner,
__private: {
clearTerminal,
getSafeTerminalWidth
getSafeTerminalWidth,
getTextBannerLineCount
}
} = require('./cli-theme');
// Fixed (non-banner, non-command) chrome lines emitted by the full-layout panel:
// blank-after-banner, top separator, input line, separator, title, blank,
// blank-before-footer, separator, footer hint = 9 lines. Keep this in sync with
// renderPanel(); the integration test asserts the rendered budget never overflows.
const FIXED_CHROME_LINES = 9;
// Fixed chrome for the compact header layout (single-line header replaces the
// 37-line banner): header, separator, input, separator, title, blank,
// blank-before-footer, separator, footer = 9 lines as well.
const COMPACT_CHROME_LINES = 9;
// Smallest command list we are willing to show in either layout.
const MIN_FULL_COMMAND_ROWS = 3;
const MIN_COMPACT_COMMAND_ROWS = 4;
const MAX_COMMAND_ROWS = 16;
// Derive the full-layout banner height from the real banner asset so layout
// budgeting tracks the banner automatically instead of drifting from a literal.
function getBannerLineCount() {
const count = typeof getTextBannerLineCount === 'function' ? getTextBannerLineCount() : 0;
return Number.isFinite(count) && count > 0 ? count : 37;
}
// Total fixed (always-present) lines for a layout, excluding the command list.
function getReservedRows(compact = false) {
return compact
? COMPACT_CHROME_LINES
: getBannerLineCount() + FIXED_CHROME_LINES;
}
const PRIMARY_COMMAND_PRIORITY = [
'check',
'help',
@ -59,6 +88,15 @@ function getTerminalRows(rows = process.stdout.rows) {
return Math.floor(value);
}
// Minimum terminal height at which the full-banner layout fits without scrolling.
// The full layout always emits getReservedRows(false) fixed lines plus at least
// MIN_FULL_COMMAND_ROWS command rows, so anything shorter must use the compact
// header instead (otherwise the panel overflows the viewport and flickers, the
// #86 artifact on Windows Terminal / PowerShell / CMD).
function getFullLayoutMinRows() {
return getReservedRows(false) + MIN_FULL_COMMAND_ROWS;
}
function shouldUseCompactPanelLayout({
rows = process.stdout.rows,
platform = process.platform,
@ -69,8 +107,13 @@ function shouldUseCompactPanelLayout({
const terminalRows = getTerminalRows(rows);
if (!terminalRows) return false;
if (platform === 'win32' && terminalRows < 64) return true;
return terminalRows < 46;
// Windows consoles keep a larger compact band: their scrollback/redraw is the
// worst offender, so favor the single-line header well past the strict fit.
const fullLayoutMinRows = getFullLayoutMinRows();
if (platform === 'win32') {
return terminalRows < Math.max(64, fullLayoutMinRows);
}
return terminalRows < fullLayoutMinRows;
}
function getMaxCommandRows({
@ -78,9 +121,9 @@ function getMaxCommandRows({
compact = false
} = {}) {
const terminalRows = getTerminalRows(rows) || 40;
const reservedRows = compact ? 9 : 47;
const minimumRows = compact ? 4 : 3;
return Math.max(minimumRows, Math.min(16, terminalRows - reservedRows));
const reservedRows = getReservedRows(compact);
const minimumRows = compact ? MIN_COMPACT_COMMAND_ROWS : MIN_FULL_COMMAND_ROWS;
return Math.max(minimumRows, Math.min(MAX_COMMAND_ROWS, terminalRows - reservedRows));
}
function tokenizeArgString(rawInput = '') {
@ -476,10 +519,17 @@ async function launchInteractivePanel(options) {
colorPhase: 0
};
let lastRenderedColorPhase = null;
const renderNow = () => {
lastRenderedColorPhase = state.colorPhase;
renderPanel(state, catalog, primaryCommands, { colorPhase: state.colorPhase });
};
// Fix #5: the startup reveal animation (animateBanner) already commits its
// final banner frame to the screen. Previously renderNow() then immediately
// cleared and redrew the whole screen a second time, producing a visible
// double-clear flash. Run the reveal once, then commit the panel chrome a
// single time on top of the same screen state.
if (!shouldUseCompactPanelLayout()) {
await animateBanner({ text: appName });
}
@ -490,6 +540,7 @@ async function launchInteractivePanel(options) {
return new Promise((resolve) => {
const shouldPulseBanner = shouldEnableBannerPulse();
let pulseTimer = null;
let resizeTimer = null;
const stopBannerPulse = () => {
if (pulseTimer) {
@ -500,15 +551,42 @@ async function launchInteractivePanel(options) {
const startBannerPulse = () => {
if (!shouldPulseBanner || pulseTimer) return;
// Fix #4: animating color no longer drives a full-screen clear+redraw
// 8x/second. Slow the cadence and only repaint when the visible color
// phase actually advanced, so an idle panel stops thrashing the
// terminal (the flicker amplifier behind issue #86).
pulseTimer = setInterval(() => {
if (state.busy) return;
state.colorPhase = (state.colorPhase + 1) % 1024;
if (state.colorPhase === lastRenderedColorPhase) return;
renderNow();
}, 120);
}, 220);
};
// Fix #3: react to terminal resizes. The pulse timer is disabled on
// Windows, so without this the panel keeps drawing at the old width until
// the next keypress, tearing the borders (the #86 artifact). Debounce so a
// drag-resize coalesces into a single repaint at the final size.
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
if (state.busy) return;
renderNow();
}, 100);
};
const stopResizeWatcher = () => {
if (resizeTimer) {
clearTimeout(resizeTimer);
resizeTimer = null;
}
process.stdout.off('resize', onResize);
};
const stopInteractiveMode = () => {
stopBannerPulse();
stopResizeWatcher();
process.stdin.off('keypress', onKeypress);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
process.stdin.pause();
@ -519,6 +597,7 @@ async function launchInteractivePanel(options) {
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
startBannerPulse();
process.stdout.on('resize', onResize);
renderNow();
};
@ -647,6 +726,14 @@ module.exports = {
shouldEnableBannerPulse,
getPanelWidth,
getMaxCommandRows,
shouldUseCompactPanelLayout
shouldUseCompactPanelLayout,
getBannerLineCount,
getReservedRows,
getFullLayoutMinRows,
FIXED_CHROME_LINES,
COMPACT_CHROME_LINES,
MIN_FULL_COMMAND_ROWS,
MIN_COMPACT_COMMAND_ROWS,
MAX_COMMAND_ROWS
}
};

View file

@ -175,8 +175,8 @@ function run() {
);
assert.strictEqual(
getSafeTerminalWidth(207, 'linux'),
207,
'Non-Windows rendering can use the reported terminal width'
206,
'All platforms reserve one trailing cell so auto-wrap terminals do not tear the panel border'
);
assert.strictEqual(
getTerminalClearSequence('win32'),
@ -210,8 +210,8 @@ function run() {
);
assert.strictEqual(
getMaxCommandRows({ rows: 50, compact: false }),
3,
'full banner layout should not overflow a 50-row terminal'
4,
'full banner layout reserves the real banner+chrome height and must not overflow a 50-row terminal'
);
const bannerLogs = captureConsoleLogs(() => {

View file

@ -31,6 +31,7 @@ const TESTS = [
{ name: 'Startup banner canonical integrity', file: 'banner-canonical.test.js', category: 'UI' },
{ name: 'CLI interface smoke', file: 'ui-cli-smoke.test.js', category: 'UI' },
{ name: 'CLI interactive panel helpers', file: 'cli-interactive-panel.test.js', category: 'UI' },
{ name: 'Windows panel overflow + resize', file: 'windows-panel-overflow.test.js', category: 'UI' },
{ name: 'Local features end-to-end', file: 'local-features-e2e.test.js', category: 'E2E' },
{ name: 'Calibration schema', file: 'calibration-schema.test.js', category: 'Calibration' },
{ name: 'Calibration command', file: 'calibrate-command.test.js', category: 'Calibration' },

View file

@ -0,0 +1,170 @@
const assert = require('assert');
const { EventEmitter } = require('events');
const interactivePanel = require('../src/ui/interactive-panel');
const {
launchInteractivePanel,
__private: {
shouldUseCompactPanelLayout,
getMaxCommandRows,
getReservedRows,
getBannerLineCount,
getFullLayoutMinRows,
FIXED_CHROME_LINES
}
} = interactivePanel;
const {
__private: {
getSafeTerminalWidth,
getTextBannerLineCount
}
} = require('../src/ui/cli-theme');
// Total lines the renderer commits for a given terminal height + platform. The
// budget mirrors renderPanel(): fixed chrome (banner-or-compact-header + panel
// chrome) plus the windowed command list. If this ever exceeds the viewport the
// panel scrolls and re-introduces the issue #86 flicker.
function renderedLineBudget(rows, platform) {
const compact = shouldUseCompactPanelLayout({ rows, platform });
const reserved = getReservedRows(compact);
const commandRows = getMaxCommandRows({ rows, compact });
return reserved + commandRows;
}
async function run() {
// (b) reservedRows (full) is derived from the real banner asset, not a magic
// literal, so layout budgeting tracks the banner automatically.
const bannerLines = getBannerLineCount();
assert.strictEqual(
bannerLines,
getTextBannerLineCount(),
'panel banner line count should come from the real banner loader'
);
assert.strictEqual(
getReservedRows(false),
bannerLines + FIXED_CHROME_LINES,
'full-layout reserved rows must equal banner lines + FIXED_CHROME_LINES (no magic-number drift)'
);
assert.strictEqual(
getFullLayoutMinRows(),
getReservedRows(false) + 3,
'full-layout minimum height must leave room for at least 3 command rows'
);
// (a) For the previously-broken band (46-49) and a spread of other heights,
// the rendered budget must never overflow the viewport on either platform.
const heights = [20, 24, 30, 40, 45, 46, 47, 48, 49, 50, 51, 60, 64, 80, 120];
for (const platform of ['win32', 'linux']) {
for (const rows of heights) {
const budget = renderedLineBudget(rows, platform);
assert.ok(
budget <= rows,
`rendered line budget ${budget} must not exceed terminal height ${rows} (${platform})`
);
}
}
// Regression guard for the exact band called out in issue #86: at 46-49 rows
// the full layout used to be chosen yet emitted ~49+ lines. It must now pick
// the compact header so the panel fits.
for (const rows of [46, 47, 48]) {
assert.strictEqual(
shouldUseCompactPanelLayout({ rows, platform: 'linux' }),
true,
`non-Windows ${rows}-row terminal must use the compact layout to avoid overflow`
);
}
// (c) getSafeTerminalWidth reserves one trailing cell on BOTH platforms so
// auto-wrap terminals (Windows console, libvte, tmux, SSH) never tear the
// persistent border on the last column.
assert.strictEqual(getSafeTerminalWidth(120, 'win32'), 119, 'win32 width should be columns-1');
assert.strictEqual(getSafeTerminalWidth(120, 'linux'), 119, 'posix width should be columns-1');
assert.strictEqual(getSafeTerminalWidth(207, 'darwin'), 206, 'darwin width should be columns-1');
// (d) A resize listener is registered on start and removed on stop, with no
// leak. Drive launchInteractivePanel against mocked, non-TTY stdio so it runs
// headless: no raw mode, no banner animation, no pulse timer.
await runResizeListenerLifecycle();
console.log('windows-panel-overflow.test.js: OK');
}
async function runResizeListenerLifecycle() {
const originalStdin = process.stdin;
const originalStdout = process.stdout;
// Minimal stdin/stdout stand-ins. Non-TTY keeps clearTerminal()/raw-mode and
// the pulse off; we only need the resize listener wired through start/stop.
const fakeStdin = new EventEmitter();
fakeStdin.isTTY = false;
fakeStdin.setRawMode = () => {};
fakeStdin.resume = () => {};
fakeStdin.pause = () => {};
const fakeStdout = new EventEmitter();
fakeStdout.isTTY = false;
fakeStdout.columns = 120;
fakeStdout.rows = 60;
fakeStdout.write = () => true;
const program = {
commands: [
{ name: () => 'check', description: () => 'Check compatibility' },
{ name: () => 'help', description: () => 'Show all commands' }
]
};
// Silence the panel's renderPanel() console.log chatter while we exercise the
// start/stop lifecycle so it does not pollute the test runner's captured output.
const originalConsoleLog = console.log;
console.log = () => {};
Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true });
Object.defineProperty(process, 'stdout', { value: fakeStdout, configurable: true });
try {
const before = fakeStdout.listenerCount('resize');
const panelPromise = launchInteractivePanel({ program, binaryPath: '/tmp/fake-bin.js' });
// launchInteractivePanel awaits animateBanner() (a no-op without a TTY)
// before attaching listeners; yield a couple of microtasks so start
// completes before we assert on the registered listener.
await Promise.resolve();
await Promise.resolve();
const during = fakeStdout.listenerCount('resize');
assert.strictEqual(
during,
before + 1,
'start should register exactly one resize listener'
);
// Trigger quit ('q') to run the stop path.
fakeStdin.emit('keypress', 'q', { name: 'q' });
await panelPromise;
const after = fakeStdout.listenerCount('resize');
assert.strictEqual(
after,
before,
'stop should remove the resize listener (no leak)'
);
} finally {
Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true });
Object.defineProperty(process, 'stdout', { value: originalStdout, configurable: true });
console.log = originalConsoleLog;
}
}
if (require.main === module) {
Promise.resolve()
.then(run)
.catch((error) => {
console.error('windows-panel-overflow.test.js: FAILED');
console.error(error);
process.exit(1);
});
}
module.exports = { run };