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.
- package/README.md +14 -1
- package/analyzer/compatibility.js +5 -0
- package/analyzer/performance.js +5 -4
- package/bin/cli.js +5 -39
- package/bin/enhanced_cli.js +88 -19
- package/bin/mcp-server.mjs +266 -101
- package/package.json +7 -7
- package/src/ai/multi-objective-selector.js +118 -11
- package/src/calibration/calibration-manager.js +4 -1
- package/src/data/model-database.js +39 -5
- package/src/data/sync-manager.js +32 -18
- package/src/hardware/backends/apple-silicon.js +5 -1
- package/src/hardware/backends/cuda-detector.js +47 -19
- package/src/hardware/backends/intel-detector.js +6 -2
- package/src/hardware/backends/rocm-detector.js +6 -2
- package/src/hardware/detector.js +57 -30
- package/src/hardware/unified-detector.js +129 -25
- package/src/models/ai-check-selector.js +36 -5
- package/src/models/deterministic-selector.js +163 -15
- package/src/models/expanded_database.js +9 -5
- package/src/models/intelligent-selector.js +87 -1
- package/src/models/requirements.js +16 -11
- package/src/models/scoring-core.js +341 -0
- package/src/models/scoring-engine.js +9 -2
- package/src/ollama/capacity-planner.js +15 -2
- package/src/ollama/client.js +70 -30
- package/src/ollama/enhanced-client.js +20 -2
- package/src/ollama/manager.js +14 -2
- package/src/policy/cli-policy.js +8 -2
- package/src/policy/policy-engine.js +2 -1
- package/src/provenance/model-provenance.js +4 -1
- package/src/ui/cli-theme.js +57 -7
- package/src/ui/interactive-panel.js +176 -20
package/src/ollama/client.js
CHANGED
|
@@ -213,7 +213,9 @@ class OllamaClient {
|
|
|
213
213
|
return [];
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
const models = data.models
|
|
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] =
|
|
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:
|
|
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
|
-
|
|
404
|
-
const lines =
|
|
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
|
-
|
|
442
|
+
let data;
|
|
407
443
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
package/src/ollama/manager.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/policy/cli-policy.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/src/ui/cli-theme.js
CHANGED
|
@@ -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
|
|
82
|
-
|
|
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 =
|
|
332
|
-
|
|
333
|
-
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
};
|