mirror of
https://github.com/signerless/llm-checker.git
synced 2026-10-19 09:54:45 +00:00
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:
parent
35805303ff
commit
520f1b50ff
5 changed files with 284 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
170
tests/windows-panel-overflow.test.js
Normal file
170
tests/windows-panel-overflow.test.js
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue