llm-checker 3.5.14 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -213,7 +213,9 @@ class OllamaClient {
213
213
  return [];
214
214
  }
215
215
 
216
- const models = data.models.map(model => this.parseOllamaModel(model));
216
+ const models = data.models
217
+ .map(model => this.parseOllamaModel(model))
218
+ .filter(Boolean);
217
219
  return models;
218
220
  } catch (error) {
219
221
  throw new Error(`Failed to fetch local models: ${error.message}`);
@@ -335,8 +337,13 @@ class OllamaClient {
335
337
  const sizeBytes = ollamaModel.size || 0;
336
338
  const sizeGB = Math.round(sizeBytes / (1024 ** 3) * 10) / 10;
337
339
 
340
+ // Guard against a malformed /api/tags entry missing its name: a single bad
341
+ // entry must not crash the whole local-model list (getLocalModels wraps this
342
+ // in one try/catch). Callers filter out null entries.
343
+ const rawName = ollamaModel.name || ollamaModel.model || '';
344
+ if (!rawName) return null;
338
345
 
339
- const [modelFamily, version] = ollamaModel.name.split(':');
346
+ const [modelFamily, version] = rawName.split(':');
340
347
  const details = ollamaModel.details || {};
341
348
 
342
349
 
@@ -355,7 +362,7 @@ class OllamaClient {
355
362
  }
356
363
 
357
364
  return {
358
- name: ollamaModel.name,
365
+ name: rawName,
359
366
  displayName: `${modelFamily} ${version || 'latest'}`,
360
367
  family: details.family || modelFamily.toLowerCase(),
361
368
  size: estimatedParams,
@@ -395,43 +402,66 @@ class OllamaClient {
395
402
  const reader = response.body.getReader();
396
403
  const decoder = new TextDecoder();
397
404
  let receivedSuccess = false;
405
+ let buffer = '';
406
+
407
+ // The pull stream is NDJSON. A single status object frequently spans two
408
+ // TCP reads, so we must keep a carry-over buffer across reads (and decode
409
+ // with { stream: true } so a multi-byte UTF-8 char split across a chunk
410
+ // boundary isn't corrupted) instead of parsing each chunk in isolation.
411
+ const handleData = (data) => {
412
+ if (onProgress && (data.status || data.completed !== undefined)) {
413
+ onProgress({
414
+ status: data.status,
415
+ completed: data.completed,
416
+ total: data.total,
417
+ percent: data.total ? Math.round((data.completed / data.total) * 100) : 0
418
+ });
419
+ }
420
+ if (data.status === 'success') {
421
+ receivedSuccess = true;
422
+ return true;
423
+ }
424
+ if (data.error) {
425
+ throw new Error(data.error);
426
+ }
427
+ return false;
428
+ };
398
429
 
399
430
  while (true) {
400
431
  const { done, value } = await reader.read();
401
432
  if (done) break;
402
433
 
403
- const chunk = decoder.decode(value);
404
- const lines = chunk.split('\n').filter(line => line.trim());
434
+ buffer += decoder.decode(value, { stream: true });
435
+ const lines = buffer.split('\n');
436
+ buffer = lines.pop() || ''; // keep the trailing partial line
437
+
438
+ for (const rawLine of lines) {
439
+ const line = rawLine.trim();
440
+ if (!line) continue;
405
441
 
406
- for (const line of lines) {
442
+ let data;
407
443
  try {
408
- const data = JSON.parse(line);
409
-
410
- if (onProgress && (data.status || data.completed !== undefined)) {
411
- onProgress({
412
- status: data.status,
413
- completed: data.completed,
414
- total: data.total,
415
- percent: data.total ? Math.round((data.completed / data.total) * 100) : 0
416
- });
417
- }
418
-
419
- if (data.status === 'success') {
420
- receivedSuccess = true;
421
- return { success: true, model: modelName };
422
- }
423
-
424
- if (data.error) {
425
- throw new Error(data.error);
426
- }
427
- } catch (e) {
428
- if (e.message && !e.message.includes('Unexpected')) {
429
- throw e; // Re-throw real errors, skip JSON parse errors
430
- }
444
+ data = JSON.parse(line);
445
+ } catch {
446
+ continue; // skip unparseable lines structurally (no message matching)
447
+ }
448
+
449
+ if (handleData(data)) {
450
+ return { success: true, model: modelName };
431
451
  }
432
452
  }
433
453
  }
434
454
 
455
+ // Flush a complete final line left in the buffer after the stream ends.
456
+ const tail = buffer.trim();
457
+ if (tail) {
458
+ let data = null;
459
+ try { data = JSON.parse(tail); } catch { data = null; }
460
+ if (data && handleData(data)) {
461
+ return { success: true, model: modelName };
462
+ }
463
+ }
464
+
435
465
  if (!receivedSuccess) {
436
466
  throw new Error('Stream ended without success confirmation');
437
467
  }
@@ -721,7 +751,17 @@ class OllamaClient {
721
751
  const handleLine = (line) => {
722
752
  if (!line.trim()) return;
723
753
 
724
- const data = JSON.parse(line);
754
+ // Skip any line that isn't valid JSON (a partial/truncated final
755
+ // line when the connection is cut, or a proxy-injected keep-alive)
756
+ // instead of throwing — an unparseable trailing line must not discard
757
+ // all of the content already accumulated from the stream.
758
+ let data;
759
+ try {
760
+ data = JSON.parse(line);
761
+ } catch {
762
+ return;
763
+ }
764
+
725
765
  const chunk = data?.message?.content || '';
726
766
  if (chunk) {
727
767
  content += chunk;
@@ -60,8 +60,26 @@ class EnhancedOllamaClient extends OllamaClient {
60
60
  }
61
61
  };
62
62
  } catch (error) {
63
- console.error('Error in enhanced compatibility check:', error);
64
- return await this.getCompatibleModels();
63
+ // Degrade gracefully when the enhanced scrape fails (e.g. ollama.com is
64
+ // unreachable). The previous fallback called this.getCompatibleModels(),
65
+ // which exists on neither this class nor its parent, so the catch itself
66
+ // threw "is not a function" and masked the real (often network) error.
67
+ console.error('Error in enhanced compatibility check:', error.message);
68
+ let installed = 0;
69
+ try {
70
+ const localModels = await this.getLocalModels();
71
+ installed = Array.isArray(localModels) ? localModels.length : 0;
72
+ } catch (_) {
73
+ installed = 0;
74
+ }
75
+ return {
76
+ installed,
77
+ compatible: 0,
78
+ compatible_models: [],
79
+ recommendations: [],
80
+ total_available: 0,
81
+ error: error.message
82
+ };
65
83
  }
66
84
  }
67
85
 
@@ -8,8 +8,11 @@ class OllamaManager extends EventEmitter {
8
8
  this.modelQueue = [];
9
9
  this.isProcessing = false;
10
10
  this.maxConcurrent = options.maxConcurrent || 1;
11
- this.autoCleanup = options.autoCleanup || true;
11
+ // `options.autoCleanup || true` was always true, so passing { autoCleanup:
12
+ // false } could not actually disable the background timer.
13
+ this.autoCleanup = options.autoCleanup !== undefined ? Boolean(options.autoCleanup) : true;
12
14
  this.cleanupInterval = options.cleanupInterval || 30 * 60 * 1000; // 30 minutes
15
+ this._cleanupTimer = null;
13
16
 
14
17
  if (this.autoCleanup) {
15
18
  this.startCleanupTimer();
@@ -295,7 +298,7 @@ class OllamaManager extends EventEmitter {
295
298
  }
296
299
 
297
300
  startCleanupTimer() {
298
- setInterval(async () => {
301
+ this._cleanupTimer = setInterval(async () => {
299
302
  try {
300
303
  const analysis = await this.cleanupUnusedModels();
301
304
  if (analysis.cleanupCandidates > 0) {
@@ -305,6 +308,11 @@ class OllamaManager extends EventEmitter {
305
308
  this.emit('error', error);
306
309
  }
307
310
  }, this.cleanupInterval);
311
+ // Don't keep the event loop (and thus the CLI process) alive just for the
312
+ // periodic cleanup timer.
313
+ if (this._cleanupTimer && typeof this._cleanupTimer.unref === 'function') {
314
+ this._cleanupTimer.unref();
315
+ }
308
316
  }
309
317
 
310
318
  async getStatistics() {
@@ -348,6 +356,10 @@ class OllamaManager extends EventEmitter {
348
356
 
349
357
  destroy() {
350
358
  // Clean up resources
359
+ if (this._cleanupTimer) {
360
+ clearInterval(this._cleanupTimer);
361
+ this._cleanupTimer = null;
362
+ }
351
363
  this.removeAllListeners();
352
364
  this.modelQueue = [];
353
365
  this.isProcessing = false;
@@ -158,7 +158,8 @@ function getCandidateTargets(model) {
158
158
  function patternToRegex(pattern) {
159
159
  const escaped = String(pattern || '')
160
160
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
161
- .replace(/\*/g, '.*');
161
+ .replace(/\*/g, '.*')
162
+ .replace(/\?/g, '.'); // glob '?' = exactly one character (not a regex quantifier)
162
163
 
163
164
  return new RegExp(`^${escaped}$`, 'i');
164
165
  }
@@ -176,7 +177,12 @@ function parseExceptionExpiry(expiresAt) {
176
177
  }
177
178
 
178
179
  function isExceptionEntryActive(entry, now) {
179
- const expiry = parseExceptionExpiry(entry?.expires_at);
180
+ const rawExpiry = entry?.expires_at;
181
+ const expiry = parseExceptionExpiry(rawExpiry);
182
+ // A provided-but-unparseable expiry (typo like '2026/06/01', free text like
183
+ // 'next friday') must NOT be treated as "no expiry" — that would silently make
184
+ // the exception permanent and suppress real policy violations. Fail closed.
185
+ if (rawExpiry && !expiry) return false;
180
186
  if (!expiry) return true;
181
187
  return expiry.getTime() >= now.getTime();
182
188
  }
@@ -494,7 +494,8 @@ class PolicyEngine {
494
494
 
495
495
  const escaped = pattern
496
496
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
497
- .replace(/\*/g, '.*');
497
+ .replace(/\*/g, '.*')
498
+ .replace(/\?/g, '.'); // glob '?' = exactly one character (not a regex quantifier)
498
499
 
499
500
  const regex = new RegExp(`^${escaped}$`, 'i');
500
501
  this.patternCache.set(pattern, regex);
@@ -83,7 +83,10 @@ function extractVersionFromIdentifier(identifier) {
83
83
  if (!text) return UNKNOWN_VALUE;
84
84
 
85
85
  if (text.includes(':')) {
86
- const [, tag] = text.split(':');
86
+ // The tag is the segment after the LAST colon. Splitting on every colon and
87
+ // taking the second segment mis-parsed registry-prefixed refs like
88
+ // 'registry.local:5000/llama3:8b' (it returned '5000/llama3' instead of '8b').
89
+ const tag = text.slice(text.lastIndexOf(':') + 1);
87
90
  return sanitizeValue(tag);
88
91
  }
89
92
 
@@ -4,6 +4,7 @@ const chalk = require('chalk');
4
4
  const { execFileSync } = require('child_process');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const readline = require('readline');
7
8
 
8
9
  // Adapted from /Users/pchmirenko/Downloads/ascii-motion-cli.tsx frame model.
9
10
  const THEME_DARK = {
@@ -41,6 +42,8 @@ const FRAMES_PER_SECOND = 14;
41
42
  // Security: do not auto-load executable-style banner sources from user-writable folders.
42
43
  // External banner loading is opt-in via LLM_CHECKER_BANNER_SOURCE and supports JSON only.
43
44
  const DEFAULT_BANNER_SOURCE = null;
45
+ // Canonical startup banner asset. Do not edit casually: treat as product identity.
46
+ // Any intentional change should update tests/banner-canonical.test.js in the same commit.
44
47
  const BUNDLED_TEXT_BANNER_SOURCE = path.join(__dirname, 'banner-profesional-v2.txt');
45
48
  const DEFAULT_TEXT_BANNER_SOURCES = [
46
49
  BUNDLED_TEXT_BANNER_SOURCE
@@ -78,8 +81,42 @@ function sleep(ms) {
78
81
  return new Promise((resolve) => setTimeout(resolve, ms));
79
82
  }
80
83
 
81
- function clearTerminal() {
82
- process.stdout.write('\x1b[2J\x1b[0f');
84
+ function getSafeTerminalWidth(columns = process.stdout.columns, platform = process.platform) {
85
+ void platform;
86
+ const value = Number(columns);
87
+ if (!Number.isFinite(value) || value <= 0) return null;
88
+
89
+ const width = Math.floor(value);
90
+ // Many terminals (Windows consoles, libvte, tmux, SSH) auto-wrap when output
91
+ // writes exactly `columns` cells, which tears the persistent panel border on
92
+ // resize/pulse. The canonical banner is centered/clipped, so reserving one
93
+ // trailing cell on every platform is invisible while avoiding the wrap.
94
+ return Math.max(20, width - 1);
95
+ }
96
+
97
+ function getTerminalClearSequence(platform = process.platform) {
98
+ if (platform === 'win32') {
99
+ return '\x1b[1;1H\x1b[0J';
100
+ }
101
+
102
+ return '\x1b[2J\x1b[H';
103
+ }
104
+
105
+ function clearTerminal(options = {}) {
106
+ if (!process.stdout.isTTY) return;
107
+
108
+ const platform = options.platform || process.platform;
109
+ if (platform === 'win32') {
110
+ process.stdout.write(getTerminalClearSequence(platform));
111
+ return;
112
+ }
113
+
114
+ try {
115
+ readline.cursorTo(process.stdout, 0, 0);
116
+ readline.clearScreenDown(process.stdout);
117
+ } catch {
118
+ process.stdout.write(getTerminalClearSequence(platform));
119
+ }
83
120
  }
84
121
 
85
122
  function parseDarkBackgroundValue(value) {
@@ -323,14 +360,20 @@ function loadTextBanner(sourceFile) {
323
360
  }
324
361
  }
325
362
 
363
+ function getTextBannerLineCount(sourceFile) {
364
+ const lines = loadTextBanner(sourceFile);
365
+ return Array.isArray(lines) ? lines.length : 0;
366
+ }
367
+
326
368
  function drawTextBanner(lines, options = {}) {
327
369
  const palette = getTextBannerPalette(options);
328
370
  const colorPhase = Number.isFinite(options.colorPhase)
329
371
  ? Math.max(0, Math.floor(options.colorPhase))
330
372
  : 0;
331
- const terminalWidth = Number.isFinite(process.stdout.columns) && process.stdout.columns > 0
332
- ? process.stdout.columns
333
- : null;
373
+ const terminalWidth = getSafeTerminalWidth(
374
+ options.columns ?? process.stdout.columns,
375
+ options.platform || process.platform
376
+ );
334
377
 
335
378
  const centerToWidth = (text, width) => {
336
379
  const value = String(text || '').replace(/\s+$/g, '');
@@ -390,7 +433,7 @@ function drawTextBanner(lines, options = {}) {
390
433
 
391
434
  const frameMatch = line.match(/^(\s*\|)(.*)(\|\s*)$/);
392
435
  if (!frameMatch) {
393
- console.log(line);
436
+ console.log(terminalWidth ? fitLine(line, terminalWidth) : line);
394
437
  continue;
395
438
  }
396
439
 
@@ -728,8 +771,15 @@ module.exports = {
728
771
  __private: {
729
772
  detectDarkBackgroundFromColorFgbg,
730
773
  detectDarkBackgroundFromMacOsAppearance,
774
+ fitLine,
775
+ drawTextBanner,
776
+ clearTerminal,
777
+ getSafeTerminalWidth,
778
+ getTerminalClearSequence,
731
779
  makeFrames,
732
780
  drawFrame,
733
- resolveHasDarkBackground
781
+ resolveHasDarkBackground,
782
+ loadTextBanner,
783
+ getTextBannerLineCount
734
784
  }
735
785
  };
@@ -4,7 +4,43 @@ const chalk = require('chalk');
4
4
  const inquirer = require('inquirer');
5
5
  const readline = require('readline');
6
6
  const { spawn } = require('child_process');
7
- const { animateBanner, renderPersistentBanner } = require('./cli-theme');
7
+ const {
8
+ animateBanner,
9
+ renderPersistentBanner,
10
+ __private: {
11
+ clearTerminal,
12
+ getSafeTerminalWidth,
13
+ getTextBannerLineCount
14
+ }
15
+ } = require('./cli-theme');
16
+
17
+ // Fixed (non-banner, non-command) chrome lines emitted by the full-layout panel:
18
+ // blank-after-banner, top separator, input line, separator, title, blank,
19
+ // blank-before-footer, separator, footer hint = 9 lines. Keep this in sync with
20
+ // renderPanel(); the integration test asserts the rendered budget never overflows.
21
+ const FIXED_CHROME_LINES = 9;
22
+ // Fixed chrome for the compact header layout (single-line header replaces the
23
+ // 37-line banner): header, separator, input, separator, title, blank,
24
+ // blank-before-footer, separator, footer = 9 lines as well.
25
+ const COMPACT_CHROME_LINES = 9;
26
+ // Smallest command list we are willing to show in either layout.
27
+ const MIN_FULL_COMMAND_ROWS = 3;
28
+ const MIN_COMPACT_COMMAND_ROWS = 4;
29
+ const MAX_COMMAND_ROWS = 16;
30
+
31
+ // Derive the full-layout banner height from the real banner asset so layout
32
+ // budgeting tracks the banner automatically instead of drifting from a literal.
33
+ function getBannerLineCount() {
34
+ const count = typeof getTextBannerLineCount === 'function' ? getTextBannerLineCount() : 0;
35
+ return Number.isFinite(count) && count > 0 ? count : 37;
36
+ }
37
+
38
+ // Total fixed (always-present) lines for a layout, excluding the command list.
39
+ function getReservedRows(compact = false) {
40
+ return compact
41
+ ? COMPACT_CHROME_LINES
42
+ : getBannerLineCount() + FIXED_CHROME_LINES;
43
+ }
8
44
 
9
45
  const PRIMARY_COMMAND_PRIORITY = [
10
46
  'check',
@@ -31,10 +67,6 @@ const REQUIRED_ARG_PROMPTS = {
31
67
  ]
32
68
  };
33
69
 
34
- function clearTerminal() {
35
- process.stdout.write('\x1b[2J\x1b[0f');
36
- }
37
-
38
70
  function truncateText(text, maxLength) {
39
71
  const value = String(text || '');
40
72
  if (value.length <= maxLength) return value;
@@ -42,6 +74,58 @@ function truncateText(text, maxLength) {
42
74
  return `${value.slice(0, maxLength - 3)}...`;
43
75
  }
44
76
 
77
+ function getPanelWidth({
78
+ columns = process.stdout.columns,
79
+ platform = process.platform
80
+ } = {}) {
81
+ const safeWidth = getSafeTerminalWidth(columns, platform) || 100;
82
+ return Math.max(40, Math.min(safeWidth, 128));
83
+ }
84
+
85
+ function getTerminalRows(rows = process.stdout.rows) {
86
+ const value = Number(rows);
87
+ if (!Number.isFinite(value) || value <= 0) return null;
88
+ return Math.floor(value);
89
+ }
90
+
91
+ // Minimum terminal height at which the full-banner layout fits without scrolling.
92
+ // The full layout always emits getReservedRows(false) fixed lines plus at least
93
+ // MIN_FULL_COMMAND_ROWS command rows, so anything shorter must use the compact
94
+ // header instead (otherwise the panel overflows the viewport and flickers, the
95
+ // #86 artifact on Windows Terminal / PowerShell / CMD).
96
+ function getFullLayoutMinRows() {
97
+ return getReservedRows(false) + MIN_FULL_COMMAND_ROWS;
98
+ }
99
+
100
+ function shouldUseCompactPanelLayout({
101
+ rows = process.stdout.rows,
102
+ platform = process.platform,
103
+ forceFullBanner = process.env.LLM_CHECKER_FORCE_FULL_PANEL_BANNER
104
+ } = {}) {
105
+ if (forceFullBanner === '1') return false;
106
+
107
+ const terminalRows = getTerminalRows(rows);
108
+ if (!terminalRows) return false;
109
+
110
+ // Windows consoles keep a larger compact band: their scrollback/redraw is the
111
+ // worst offender, so favor the single-line header well past the strict fit.
112
+ const fullLayoutMinRows = getFullLayoutMinRows();
113
+ if (platform === 'win32') {
114
+ return terminalRows < Math.max(64, fullLayoutMinRows);
115
+ }
116
+ return terminalRows < fullLayoutMinRows;
117
+ }
118
+
119
+ function getMaxCommandRows({
120
+ rows = process.stdout.rows,
121
+ compact = false
122
+ } = {}) {
123
+ const terminalRows = getTerminalRows(rows) || 40;
124
+ const reservedRows = getReservedRows(compact);
125
+ const minimumRows = compact ? MIN_COMPACT_COMMAND_ROWS : MIN_FULL_COMMAND_ROWS;
126
+ return Math.max(minimumRows, Math.min(MAX_COMMAND_ROWS, terminalRows - reservedRows));
127
+ }
128
+
45
129
  function tokenizeArgString(rawInput = '') {
46
130
  const tokens = [];
47
131
  let current = '';
@@ -245,10 +329,16 @@ function getCommandWindow(commands, selectedIndex, maxRows) {
245
329
 
246
330
  function renderPanel(state, catalog, primaryCommands, options = {}) {
247
331
  const colorPhase = Number.isFinite(options.colorPhase) ? options.colorPhase : 0;
248
- const width = Math.max(76, Math.min(process.stdout.columns || 100, 128));
332
+ const platform = options.platform || process.platform;
333
+ const rows = options.rows ?? process.stdout.rows;
334
+ const columns = options.columns ?? process.stdout.columns;
335
+ const compact = typeof options.compact === 'boolean'
336
+ ? options.compact
337
+ : shouldUseCompactPanelLayout({ rows, platform });
338
+ const width = getPanelWidth({ columns, platform });
249
339
  const separator = '-'.repeat(width - 2);
250
340
  const visibleCommands = getVisibleCommands(state, catalog, primaryCommands);
251
- const maxCommandRows = Math.max(6, Math.min(16, (process.stdout.rows || 40) - 28));
341
+ const maxCommandRows = getMaxCommandRows({ rows, compact });
252
342
  const selectedIndex =
253
343
  visibleCommands.length > 0
254
344
  ? Math.max(0, Math.min(state.selected, visibleCommands.length - 1))
@@ -258,11 +348,17 @@ function renderPanel(state, catalog, primaryCommands, options = {}) {
258
348
  }
259
349
  const commandWindow = getCommandWindow(visibleCommands, selectedIndex, maxCommandRows);
260
350
 
261
- clearTerminal();
262
- renderPersistentBanner(undefined, { colorPhase });
263
- console.log('');
351
+ clearTerminal({ platform });
352
+ if (compact) {
353
+ console.log(chalk.cyan.bold('llm-checker | Interactive command panel'));
354
+ } else {
355
+ renderPersistentBanner(undefined, { colorPhase, columns, platform });
356
+ console.log('');
357
+ }
264
358
  console.log(chalk.gray(separator));
265
- const inputLabel = state.paletteOpen ? `/${state.query}` : '';
359
+ const inputLabel = state.paletteOpen
360
+ ? truncateText(`/${state.query}`, Math.max(1, width - 4))
361
+ : '';
266
362
  console.log(`${chalk.white.bold('>')} ${chalk.white(inputLabel)}${chalk.gray('|')}`);
267
363
  console.log(chalk.gray(separator));
268
364
 
@@ -275,8 +371,8 @@ function renderPanel(state, catalog, primaryCommands, options = {}) {
275
371
  if (visibleCommands.length === 0) {
276
372
  console.log(chalk.yellow('No commands match your query.'));
277
373
  } else {
278
- const commandCol = 26;
279
- const descCol = Math.max(18, width - commandCol - 8);
374
+ const commandCol = Math.min(26, Math.max(12, Math.floor(width * 0.36)));
375
+ const descCol = Math.max(8, width - commandCol - 2);
280
376
  const hiddenAbove = commandWindow.start;
281
377
  const hiddenBelow = visibleCommands.length - commandWindow.end;
282
378
 
@@ -307,10 +403,22 @@ function renderPanel(state, catalog, primaryCommands, options = {}) {
307
403
  console.log('');
308
404
  console.log(chalk.gray(separator));
309
405
  console.log(
310
- chalk.gray('up/down navigate | Enter run | / open all | Esc close all | q exit')
406
+ chalk.gray(truncateText('up/down navigate | Enter run | / open all | Esc close all | q exit', width))
311
407
  );
312
408
  }
313
409
 
410
+ function shouldEnableBannerPulse({
411
+ isTTY = process.stdout.isTTY,
412
+ disableAnimation = process.env.LLM_CHECKER_DISABLE_ANIMATION,
413
+ forcePulse = process.env.LLM_CHECKER_FORCE_PANEL_PULSE,
414
+ platform = process.platform
415
+ } = {}) {
416
+ if (!isTTY) return false;
417
+ if (disableAnimation === '1') return false;
418
+ if (forcePulse === '1') return true;
419
+ return platform !== 'win32';
420
+ }
421
+
314
422
  async function collectCommandArgs(commandMeta) {
315
423
  const prompts = [];
316
424
  const requiredPrompts = REQUIRED_ARG_PROMPTS[commandMeta.name] || [];
@@ -411,20 +519,28 @@ async function launchInteractivePanel(options) {
411
519
  colorPhase: 0
412
520
  };
413
521
 
522
+ let lastRenderedColorPhase = null;
414
523
  const renderNow = () => {
524
+ lastRenderedColorPhase = state.colorPhase;
415
525
  renderPanel(state, catalog, primaryCommands, { colorPhase: state.colorPhase });
416
526
  };
417
527
 
418
- await animateBanner({ text: appName });
528
+ // Fix #5: the startup reveal animation (animateBanner) already commits its
529
+ // final banner frame to the screen. Previously renderNow() then immediately
530
+ // cleared and redrew the whole screen a second time, producing a visible
531
+ // double-clear flash. Run the reveal once, then commit the panel chrome a
532
+ // single time on top of the same screen state.
533
+ if (!shouldUseCompactPanelLayout()) {
534
+ await animateBanner({ text: appName });
535
+ }
419
536
  renderNow();
420
537
 
421
538
  readline.emitKeypressEvents(process.stdin);
422
539
 
423
540
  return new Promise((resolve) => {
424
- const shouldPulseBanner =
425
- process.stdout.isTTY &&
426
- process.env.LLM_CHECKER_DISABLE_ANIMATION !== '1';
541
+ const shouldPulseBanner = shouldEnableBannerPulse();
427
542
  let pulseTimer = null;
543
+ let resizeTimer = null;
428
544
 
429
545
  const stopBannerPulse = () => {
430
546
  if (pulseTimer) {
@@ -435,15 +551,42 @@ async function launchInteractivePanel(options) {
435
551
 
436
552
  const startBannerPulse = () => {
437
553
  if (!shouldPulseBanner || pulseTimer) return;
554
+ // Fix #4: animating color no longer drives a full-screen clear+redraw
555
+ // 8x/second. Slow the cadence and only repaint when the visible color
556
+ // phase actually advanced, so an idle panel stops thrashing the
557
+ // terminal (the flicker amplifier behind issue #86).
438
558
  pulseTimer = setInterval(() => {
439
559
  if (state.busy) return;
440
560
  state.colorPhase = (state.colorPhase + 1) % 1024;
561
+ if (state.colorPhase === lastRenderedColorPhase) return;
441
562
  renderNow();
442
- }, 120);
563
+ }, 220);
564
+ };
565
+
566
+ // Fix #3: react to terminal resizes. The pulse timer is disabled on
567
+ // Windows, so without this the panel keeps drawing at the old width until
568
+ // the next keypress, tearing the borders (the #86 artifact). Debounce so a
569
+ // drag-resize coalesces into a single repaint at the final size.
570
+ const onResize = () => {
571
+ if (resizeTimer) clearTimeout(resizeTimer);
572
+ resizeTimer = setTimeout(() => {
573
+ resizeTimer = null;
574
+ if (state.busy) return;
575
+ renderNow();
576
+ }, 100);
577
+ };
578
+
579
+ const stopResizeWatcher = () => {
580
+ if (resizeTimer) {
581
+ clearTimeout(resizeTimer);
582
+ resizeTimer = null;
583
+ }
584
+ process.stdout.off('resize', onResize);
443
585
  };
444
586
 
445
587
  const stopInteractiveMode = () => {
446
588
  stopBannerPulse();
589
+ stopResizeWatcher();
447
590
  process.stdin.off('keypress', onKeypress);
448
591
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
449
592
  process.stdin.pause();
@@ -454,6 +597,7 @@ async function launchInteractivePanel(options) {
454
597
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
455
598
  process.stdin.resume();
456
599
  startBannerPulse();
600
+ process.stdout.on('resize', onResize);
457
601
  renderNow();
458
602
  };
459
603
 
@@ -578,6 +722,18 @@ module.exports = {
578
722
  buildCommandCatalog,
579
723
  buildPrimaryCommands,
580
724
  getVisibleCommands,
581
- truncateText
725
+ truncateText,
726
+ shouldEnableBannerPulse,
727
+ getPanelWidth,
728
+ getMaxCommandRows,
729
+ shouldUseCompactPanelLayout,
730
+ getBannerLineCount,
731
+ getReservedRows,
732
+ getFullLayoutMinRows,
733
+ FIXED_CHROME_LINES,
734
+ COMPACT_CHROME_LINES,
735
+ MIN_FULL_COMMAND_ROWS,
736
+ MIN_COMPACT_COMMAND_ROWS,
737
+ MAX_COMMAND_ROWS
582
738
  }
583
739
  };