llm-checker 3.4.2 → 3.5.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 +32 -10
- package/analyzer/performance.js +40 -94
- package/bin/enhanced_cli.js +320 -254
- package/bin/mcp-server.mjs +0 -0
- package/package.json +1 -1
- package/src/models/ai-check-selector.js +2 -2
- package/src/models/deterministic-selector.js +1 -0
- package/src/models/expanded_database.js +10 -83
- package/src/ollama/client.js +29 -4
- package/src/ui/cli-theme.js +733 -0
- package/src/ui/interactive-panel.js +599 -0
- package/src/utils/fetch.js +17 -0
- package/src/utils/token-speed-estimator.js +207 -0
- package/src/ollama/gpu-placement-planner.js +0 -496
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
// Adapted from /Users/pchmirenko/Downloads/ascii-motion-cli.tsx frame model.
|
|
9
|
+
const THEME_DARK = {
|
|
10
|
+
border: '#6b7280',
|
|
11
|
+
scan: '#56606e',
|
|
12
|
+
outline: '#e2e8f0',
|
|
13
|
+
accent: '#67e8f9',
|
|
14
|
+
logo: '#67e8f9',
|
|
15
|
+
byline: '#facc15',
|
|
16
|
+
subtitle: '#94a3b8',
|
|
17
|
+
muted: '#222a34'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const THEME_LIGHT = {
|
|
21
|
+
border: '#475569',
|
|
22
|
+
scan: '#64748b',
|
|
23
|
+
outline: '#0f172a',
|
|
24
|
+
accent: '#0891b2',
|
|
25
|
+
logo: '#0f172a',
|
|
26
|
+
byline: '#854d0e',
|
|
27
|
+
subtitle: '#334155',
|
|
28
|
+
muted: '#cbd5e1'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const LOGO_LINES = [
|
|
32
|
+
' _ _ __ __ ____ _ _ ',
|
|
33
|
+
'| | | | | \\/ | / ___| |__ ___ ___| | _____ _ __ ',
|
|
34
|
+
"| | | | | |\\/| | | | | '_ \\ / _ \\/ __| |/ / _ \\ '__|",
|
|
35
|
+
'| |___ | |___ | | | | | |___| | | | __/ (__| < __/ | ',
|
|
36
|
+
'|_____||_____||_| |_| \\____|_| |_|\\___|\\___|_|\\_\\___|_| '
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const MASCOT_MASK = [
|
|
40
|
+
' /\\_/\\ ',
|
|
41
|
+
' / o o \\ ',
|
|
42
|
+
' ( ^ ) ',
|
|
43
|
+
' \\ _ / ',
|
|
44
|
+
' /___\\ ',
|
|
45
|
+
' / \\ ',
|
|
46
|
+
' (_/ \\_) '
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const DEFAULT_LOOP = true;
|
|
50
|
+
const FRAMES_PER_SECOND = 14;
|
|
51
|
+
const DEFAULT_BANNER_SOURCE = path.join(os.homedir(), 'Downloads', 'ascii-motion-cli.tsx');
|
|
52
|
+
const DEFAULT_TEXT_BANNER_SOURCE = path.join(
|
|
53
|
+
os.homedir(),
|
|
54
|
+
'Desktop',
|
|
55
|
+
'llm-checker',
|
|
56
|
+
'banner-profesional-v2.txt'
|
|
57
|
+
);
|
|
58
|
+
let cachedExternalBanner = null;
|
|
59
|
+
let cachedTextBanner = null;
|
|
60
|
+
|
|
61
|
+
function sleep(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clearTerminal() {
|
|
66
|
+
process.stdout.write('\x1b[2J\x1b[0f');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fitLine(line, width) {
|
|
70
|
+
const value = String(line || '');
|
|
71
|
+
if (value.length <= width) return value;
|
|
72
|
+
if (value.trim().length === 0) return ' '.repeat(width);
|
|
73
|
+
if (width <= 3) return value.slice(0, width);
|
|
74
|
+
return `${value.slice(0, width - 3)}...`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractBalanced(source, startIndex, openChar, closeChar) {
|
|
78
|
+
if (startIndex < 0 || source[startIndex] !== openChar) return null;
|
|
79
|
+
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let inString = null;
|
|
82
|
+
let escape = false;
|
|
83
|
+
|
|
84
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
85
|
+
const char = source[index];
|
|
86
|
+
|
|
87
|
+
if (inString) {
|
|
88
|
+
if (escape) {
|
|
89
|
+
escape = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (char === '\\') {
|
|
94
|
+
escape = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (char === inString) {
|
|
99
|
+
inString = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
106
|
+
inString = char;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (char === openChar) {
|
|
111
|
+
depth += 1;
|
|
112
|
+
} else if (char === closeChar) {
|
|
113
|
+
depth -= 1;
|
|
114
|
+
if (depth === 0) {
|
|
115
|
+
return source.slice(startIndex, index + 1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extractAssignedLiteral(source, constName, openChar, closeChar) {
|
|
124
|
+
const marker = `const ${constName}`;
|
|
125
|
+
const markerIndex = source.indexOf(marker);
|
|
126
|
+
if (markerIndex < 0) return null;
|
|
127
|
+
|
|
128
|
+
const equalsIndex = source.indexOf('=', markerIndex);
|
|
129
|
+
if (equalsIndex < 0) return null;
|
|
130
|
+
|
|
131
|
+
const startIndex = source.indexOf(openChar, equalsIndex);
|
|
132
|
+
if (startIndex < 0) return null;
|
|
133
|
+
|
|
134
|
+
return extractBalanced(source, startIndex, openChar, closeChar);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function evaluateLiteral(literal) {
|
|
138
|
+
if (!literal) return null;
|
|
139
|
+
try {
|
|
140
|
+
return Function(`"use strict"; return (${literal});`)();
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseNumericConstant(source, constName) {
|
|
147
|
+
const match = source.match(new RegExp(`const\\s+${constName}\\s*=\\s*(\\d+(?:\\.\\d+)?)`));
|
|
148
|
+
if (!match) return null;
|
|
149
|
+
const parsed = Number.parseFloat(match[1]);
|
|
150
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getLongestFrameLine(frames) {
|
|
154
|
+
let longest = 0;
|
|
155
|
+
for (const frame of frames) {
|
|
156
|
+
const rows = Array.isArray(frame.content) ? frame.content : [];
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
longest = Math.max(longest, String(row || '').length);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return longest;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeExternalFrame(frame, contentWidth, defaultDuration) {
|
|
165
|
+
const sourceRows = Array.isArray(frame.content) ? frame.content : [];
|
|
166
|
+
const content = sourceRows.map((line) => fitLine(line, contentWidth).padEnd(contentWidth, ' '));
|
|
167
|
+
const duration = Number.isFinite(frame.duration) ? frame.duration : defaultDuration;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
duration,
|
|
171
|
+
content,
|
|
172
|
+
fgColors: frame.fgColors && typeof frame.fgColors === 'object' ? frame.fgColors : {},
|
|
173
|
+
bgColors: frame.bgColors && typeof frame.bgColors === 'object' ? frame.bgColors : {}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function loadExternalBanner(sourceFile) {
|
|
178
|
+
const filePath = sourceFile || process.env.LLM_CHECKER_BANNER_SOURCE || DEFAULT_BANNER_SOURCE;
|
|
179
|
+
let mtimeMs = -1;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const stat = fs.statSync(filePath);
|
|
183
|
+
mtimeMs = stat.mtimeMs;
|
|
184
|
+
} catch {
|
|
185
|
+
cachedExternalBanner = {
|
|
186
|
+
filePath,
|
|
187
|
+
mtimeMs: -1,
|
|
188
|
+
payload: null
|
|
189
|
+
};
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
cachedExternalBanner &&
|
|
195
|
+
cachedExternalBanner.filePath === filePath &&
|
|
196
|
+
cachedExternalBanner.mtimeMs === mtimeMs
|
|
197
|
+
) {
|
|
198
|
+
return cachedExternalBanner.payload;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const source = fs.readFileSync(filePath, 'utf8');
|
|
203
|
+
const framesLiteral = extractAssignedLiteral(source, 'FRAMES', '[', ']');
|
|
204
|
+
const darkThemeLiteral = extractAssignedLiteral(source, 'THEME_DARK', '{', '}');
|
|
205
|
+
const lightThemeLiteral = extractAssignedLiteral(source, 'THEME_LIGHT', '{', '}');
|
|
206
|
+
|
|
207
|
+
const frames = evaluateLiteral(framesLiteral);
|
|
208
|
+
const themeDark = evaluateLiteral(darkThemeLiteral);
|
|
209
|
+
const themeLight = evaluateLiteral(lightThemeLiteral);
|
|
210
|
+
const canvasWidth = parseNumericConstant(source, 'CANVAS_WIDTH');
|
|
211
|
+
|
|
212
|
+
if (!Array.isArray(frames) || frames.length === 0) {
|
|
213
|
+
cachedExternalBanner = {
|
|
214
|
+
filePath,
|
|
215
|
+
mtimeMs,
|
|
216
|
+
payload: null
|
|
217
|
+
};
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const payload = {
|
|
222
|
+
frames,
|
|
223
|
+
themeDark: themeDark && typeof themeDark === 'object' ? themeDark : {},
|
|
224
|
+
themeLight: themeLight && typeof themeLight === 'object' ? themeLight : {},
|
|
225
|
+
canvasWidth: Number.isFinite(canvasWidth) ? canvasWidth : null
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
cachedExternalBanner = {
|
|
229
|
+
filePath,
|
|
230
|
+
mtimeMs,
|
|
231
|
+
payload
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return payload;
|
|
235
|
+
} catch {
|
|
236
|
+
cachedExternalBanner = {
|
|
237
|
+
filePath,
|
|
238
|
+
mtimeMs,
|
|
239
|
+
payload: null
|
|
240
|
+
};
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function loadTextBanner(sourceFile) {
|
|
246
|
+
const filePath =
|
|
247
|
+
sourceFile ||
|
|
248
|
+
process.env.LLM_CHECKER_TEXT_BANNER_SOURCE ||
|
|
249
|
+
DEFAULT_TEXT_BANNER_SOURCE;
|
|
250
|
+
let mtimeMs = -1;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const stat = fs.statSync(filePath);
|
|
254
|
+
mtimeMs = stat.mtimeMs;
|
|
255
|
+
} catch {
|
|
256
|
+
cachedTextBanner = {
|
|
257
|
+
filePath,
|
|
258
|
+
mtimeMs: -1,
|
|
259
|
+
lines: null
|
|
260
|
+
};
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
cachedTextBanner &&
|
|
266
|
+
cachedTextBanner.filePath === filePath &&
|
|
267
|
+
cachedTextBanner.mtimeMs === mtimeMs
|
|
268
|
+
) {
|
|
269
|
+
return cachedTextBanner.lines;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
274
|
+
const lines = String(raw).split(/\r?\n/);
|
|
275
|
+
cachedTextBanner = {
|
|
276
|
+
filePath,
|
|
277
|
+
mtimeMs,
|
|
278
|
+
lines
|
|
279
|
+
};
|
|
280
|
+
return lines;
|
|
281
|
+
} catch {
|
|
282
|
+
cachedTextBanner = {
|
|
283
|
+
filePath,
|
|
284
|
+
mtimeMs,
|
|
285
|
+
lines: null
|
|
286
|
+
};
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function drawTextBanner(lines, options = {}) {
|
|
292
|
+
const colorPhase = Number.isFinite(options.colorPhase)
|
|
293
|
+
? Math.max(0, Math.floor(options.colorPhase))
|
|
294
|
+
: 0;
|
|
295
|
+
const terminalWidth = Number.isFinite(process.stdout.columns) && process.stdout.columns > 0
|
|
296
|
+
? process.stdout.columns
|
|
297
|
+
: null;
|
|
298
|
+
|
|
299
|
+
const centerToWidth = (text, width) => {
|
|
300
|
+
const value = String(text || '').replace(/\s+$/g, '');
|
|
301
|
+
if (!Number.isFinite(width) || width <= 0) return value;
|
|
302
|
+
if (value.length >= width) return value.slice(0, width);
|
|
303
|
+
const left = Math.floor((width - value.length) / 2);
|
|
304
|
+
const right = width - value.length - left;
|
|
305
|
+
return `${' '.repeat(left)}${value}${' '.repeat(right)}`;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const fitLogoToWidth = (text, width) => {
|
|
309
|
+
if (!Number.isFinite(width) || width <= 0) return String(text || '');
|
|
310
|
+
|
|
311
|
+
const base = String(text || '').replace(/\s+$/g, '');
|
|
312
|
+
if (base.length <= width) {
|
|
313
|
+
return centerToWidth(base, width);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Do not distort glyphs. If still too wide, degrade to readable fallback.
|
|
317
|
+
return centerToWidth(width >= 24 ? 'LLM-CHECKER' : 'LLM', width);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const colorizeDosRebelLine = (text) => {
|
|
321
|
+
const solidPalette = ['#F8FAFC', '#E2ECFF', '#DBEAFE', '#E2ECFF'];
|
|
322
|
+
const shadePalette = ['#93C5FD', '#60A5FA', '#38BDF8', '#22D3EE', '#38BDF8', '#60A5FA'];
|
|
323
|
+
let out = '';
|
|
324
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
325
|
+
const ch = text[index];
|
|
326
|
+
if (ch === '█') {
|
|
327
|
+
const tone = solidPalette[(index + colorPhase) % solidPalette.length];
|
|
328
|
+
out += chalk.hex(tone)(ch);
|
|
329
|
+
} else if (ch === '░' || ch === '▒' || ch === '▓') {
|
|
330
|
+
const tone = shadePalette[(index + colorPhase) % shadePalette.length];
|
|
331
|
+
out += chalk.hex(tone)(ch);
|
|
332
|
+
} else {
|
|
333
|
+
out += ch;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
if (!line) {
|
|
341
|
+
console.log('');
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (/^\s*\+[-+]+\+\s*$/.test(line)) {
|
|
346
|
+
if (terminalWidth && terminalWidth >= 10) {
|
|
347
|
+
const inner = Math.max(6, terminalWidth - 4);
|
|
348
|
+
console.log(chalk.hex('#0066FF')(` +${'-'.repeat(inner)}+ `));
|
|
349
|
+
} else {
|
|
350
|
+
console.log(chalk.hex('#0066FF')(line));
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const frameMatch = line.match(/^(\s*\|)(.*)(\|\s*)$/);
|
|
356
|
+
if (!frameMatch) {
|
|
357
|
+
console.log(line);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const left = chalk.hex('#0066FF')(frameMatch[1]);
|
|
362
|
+
const right = chalk.hex('#0066FF')(frameMatch[3]);
|
|
363
|
+
const content = frameMatch[2];
|
|
364
|
+
const maxInnerWidth = terminalWidth
|
|
365
|
+
? Math.max(0, terminalWidth - (frameMatch[1].length + frameMatch[3].length))
|
|
366
|
+
: null;
|
|
367
|
+
const isDosRebelLike =
|
|
368
|
+
content.includes('█') ||
|
|
369
|
+
content.includes('░') ||
|
|
370
|
+
content.includes('▒') ||
|
|
371
|
+
content.includes('▓');
|
|
372
|
+
|
|
373
|
+
let fittedContent = content;
|
|
374
|
+
if (Number.isFinite(maxInnerWidth)) {
|
|
375
|
+
if (isDosRebelLike) {
|
|
376
|
+
fittedContent = fitLogoToWidth(content, maxInnerWidth);
|
|
377
|
+
} else {
|
|
378
|
+
const trimmed = content.trim();
|
|
379
|
+
fittedContent = trimmed.length === 0
|
|
380
|
+
? ' '.repeat(maxInnerWidth)
|
|
381
|
+
: centerToWidth(trimmed, maxInnerWidth);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let inner = fittedContent;
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
fittedContent.includes('INTELLIGENT OLLAMA MODEL SELECTOR') ||
|
|
389
|
+
fittedContent.includes('Deterministic scoring across') ||
|
|
390
|
+
fittedContent.includes('Run: llm-checker recommend')
|
|
391
|
+
) {
|
|
392
|
+
inner = chalk.hex('#60A5FA')(fittedContent);
|
|
393
|
+
} else if (
|
|
394
|
+
fittedContent.includes('[200+ DYNAMIC MODELS]') ||
|
|
395
|
+
fittedContent.includes('[35+ FALLBACK]') ||
|
|
396
|
+
fittedContent.includes('[4D SCORING]') ||
|
|
397
|
+
fittedContent.includes('[MULTI-GPU]') ||
|
|
398
|
+
fittedContent.includes('[MCP SERVER]')
|
|
399
|
+
) {
|
|
400
|
+
inner = chalk.hex('#A7F3D0')(fittedContent);
|
|
401
|
+
} else if (
|
|
402
|
+
fittedContent.includes('AI-powered CLI for hardware-aware local LLM recommendations')
|
|
403
|
+
) {
|
|
404
|
+
inner = chalk.hex('#C7D2FE')(fittedContent);
|
|
405
|
+
} else if (
|
|
406
|
+
fittedContent.includes('github.com/Pavelevich/llm-checker') ||
|
|
407
|
+
fittedContent.includes('npmjs.com/package/llm-checker')
|
|
408
|
+
) {
|
|
409
|
+
inner = chalk.hex('#3B82F6')(fittedContent);
|
|
410
|
+
} else if (fittedContent.includes('Install: npm install -g llm-checker')) {
|
|
411
|
+
inner = chalk.hex('#F8FAFC')(fittedContent);
|
|
412
|
+
} else if (
|
|
413
|
+
fittedContent.includes('█') ||
|
|
414
|
+
fittedContent.includes('░') ||
|
|
415
|
+
fittedContent.includes('▒') ||
|
|
416
|
+
fittedContent.includes('▓')
|
|
417
|
+
) {
|
|
418
|
+
inner = colorizeDosRebelLine(fittedContent);
|
|
419
|
+
} else if (
|
|
420
|
+
/[_\\\/|]/.test(fittedContent) ||
|
|
421
|
+
fittedContent.includes('____') ||
|
|
422
|
+
fittedContent.includes('▀') ||
|
|
423
|
+
fittedContent.includes('▄')
|
|
424
|
+
) {
|
|
425
|
+
inner = chalk.hex('#F8FAFC')(fittedContent);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
console.log(left + inner + right);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function buildScanline(width, row, phase) {
|
|
433
|
+
const stripe = (row + phase) % 2 === 0 ? '=' : '-';
|
|
434
|
+
return stripe.repeat(width);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function applyMask(baseLine, maskLine) {
|
|
438
|
+
if (!maskLine) return baseLine;
|
|
439
|
+
|
|
440
|
+
const result = baseLine.split('');
|
|
441
|
+
const limit = Math.min(baseLine.length, maskLine.length);
|
|
442
|
+
for (let index = 0; index < limit; index += 1) {
|
|
443
|
+
const symbol = maskLine[index];
|
|
444
|
+
if (symbol !== ' ') result[index] = symbol;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return result.join('');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function buildMascotLines(phase) {
|
|
451
|
+
const width = 34;
|
|
452
|
+
const rows = 11;
|
|
453
|
+
const maskOffset = 2;
|
|
454
|
+
const lines = [];
|
|
455
|
+
|
|
456
|
+
for (let row = 0; row < rows; row += 1) {
|
|
457
|
+
const maskLine = MASCOT_MASK[row - maskOffset];
|
|
458
|
+
lines.push(applyMask(buildScanline(width, row, phase), maskLine));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return lines;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function buildRows(phase) {
|
|
465
|
+
return [
|
|
466
|
+
...buildMascotLines(phase).map((text) => ({ kind: 'mascot', text })),
|
|
467
|
+
{ kind: 'blank', text: '' },
|
|
468
|
+
...LOGO_LINES.map((text) => ({ kind: 'logo', text })),
|
|
469
|
+
{ kind: 'blank', text: '' },
|
|
470
|
+
{ kind: 'byline', text: 'by Pavelevich' },
|
|
471
|
+
{ kind: 'subtitle', text: 'Interactive command panel' }
|
|
472
|
+
];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function classifyMascotColor(char) {
|
|
476
|
+
if (char === '=' || char === '-') return 'scan';
|
|
477
|
+
if (char === 'o' || char === '^') return 'accent';
|
|
478
|
+
if (char === '/' || char === '\\' || char === '(' || char === ')' || char === '_') {
|
|
479
|
+
return 'outline';
|
|
480
|
+
}
|
|
481
|
+
return 'outline';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function colorKeyForChar(kind, char, visible) {
|
|
485
|
+
if (!visible) return 'muted';
|
|
486
|
+
|
|
487
|
+
if (kind === 'blank') return 'muted';
|
|
488
|
+
if (kind === 'logo') return 'logo';
|
|
489
|
+
if (kind === 'byline') return 'byline';
|
|
490
|
+
if (kind === 'subtitle') return 'subtitle';
|
|
491
|
+
if (kind === 'mascot') return classifyMascotColor(char);
|
|
492
|
+
return 'logo';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function createFrameData(progress, phase, contentWidth, frameDuration) {
|
|
496
|
+
const sourceRows = buildRows(phase);
|
|
497
|
+
const content = [];
|
|
498
|
+
const fgColors = {};
|
|
499
|
+
|
|
500
|
+
for (let y = 0; y < sourceRows.length; y += 1) {
|
|
501
|
+
const row = sourceRows[y];
|
|
502
|
+
const fitted = fitLine(row.text, contentWidth).padEnd(contentWidth, ' ');
|
|
503
|
+
const visibleChars = Math.floor(fitted.length * progress);
|
|
504
|
+
content.push(fitted);
|
|
505
|
+
|
|
506
|
+
for (let x = 0; x < fitted.length; x += 1) {
|
|
507
|
+
const char = fitted[x];
|
|
508
|
+
const visible = x < visibleChars;
|
|
509
|
+
const colorKey = colorKeyForChar(row.kind, char, visible);
|
|
510
|
+
if (colorKey) {
|
|
511
|
+
fgColors[`${x},${y}`] = colorKey;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
duration: frameDuration,
|
|
518
|
+
content,
|
|
519
|
+
fgColors,
|
|
520
|
+
bgColors: {}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolveTheme(hasDarkBackground, externalTheme = null) {
|
|
525
|
+
const base = hasDarkBackground ? THEME_DARK : THEME_LIGHT;
|
|
526
|
+
if (!externalTheme || typeof externalTheme !== 'object') return base;
|
|
527
|
+
return { ...base, ...externalTheme };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function resolveTerminalWidth(preferredWidth, maxContentWidth) {
|
|
531
|
+
const terminalWidth = process.stdout.columns || preferredWidth;
|
|
532
|
+
const maxWidth = Math.max(24, terminalWidth - 2);
|
|
533
|
+
const fallbackLongest = buildRows(0).reduce((max, row) => Math.max(max, row.text.length), 0);
|
|
534
|
+
const longestLine = Math.max(0, maxContentWidth || fallbackLongest);
|
|
535
|
+
const minWidth = longestLine + 4;
|
|
536
|
+
|
|
537
|
+
return Math.min(Math.max(preferredWidth, minWidth), maxWidth);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function makeFrames(options = {}) {
|
|
541
|
+
const {
|
|
542
|
+
frameCount = 16,
|
|
543
|
+
width = 74,
|
|
544
|
+
hasDarkBackground = true,
|
|
545
|
+
frameDurationMs = Math.round(1000 / FRAMES_PER_SECOND),
|
|
546
|
+
sourceFile
|
|
547
|
+
} = options;
|
|
548
|
+
|
|
549
|
+
const externalBanner = loadExternalBanner(sourceFile);
|
|
550
|
+
if (externalBanner) {
|
|
551
|
+
const longestExternalLine = Math.max(
|
|
552
|
+
getLongestFrameLine(externalBanner.frames),
|
|
553
|
+
externalBanner.canvasWidth || 0
|
|
554
|
+
);
|
|
555
|
+
const resolvedWidth = resolveTerminalWidth(width, longestExternalLine);
|
|
556
|
+
const contentWidth = resolvedWidth - 4;
|
|
557
|
+
const externalTheme = hasDarkBackground
|
|
558
|
+
? externalBanner.themeDark
|
|
559
|
+
: externalBanner.themeLight;
|
|
560
|
+
const theme = resolveTheme(hasDarkBackground, externalTheme);
|
|
561
|
+
|
|
562
|
+
const sourceFrames = externalBanner.frames.length > 0
|
|
563
|
+
? externalBanner.frames
|
|
564
|
+
: [{ content: [''], fgColors: {}, bgColors: {}, duration: frameDurationMs }];
|
|
565
|
+
|
|
566
|
+
const externalFrames = sourceFrames.map((frame) =>
|
|
567
|
+
normalizeExternalFrame(frame, contentWidth, frameDurationMs)
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
if (frameCount <= 1) {
|
|
571
|
+
return {
|
|
572
|
+
width: resolvedWidth,
|
|
573
|
+
theme,
|
|
574
|
+
frames: [externalFrames[externalFrames.length - 1]]
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
width: resolvedWidth,
|
|
580
|
+
theme,
|
|
581
|
+
frames: externalFrames
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const resolvedWidth = resolveTerminalWidth(width);
|
|
586
|
+
const contentWidth = resolvedWidth - 4;
|
|
587
|
+
const frames = [];
|
|
588
|
+
|
|
589
|
+
for (let frameIndex = 0; frameIndex < Math.max(1, frameCount); frameIndex += 1) {
|
|
590
|
+
const progress = frameCount <= 1 ? 1 : frameIndex / (frameCount - 1);
|
|
591
|
+
const phase = frameIndex % 2;
|
|
592
|
+
frames.push(createFrameData(progress, phase, contentWidth, frameDurationMs));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
width: resolvedWidth,
|
|
597
|
+
theme: resolveTheme(hasDarkBackground),
|
|
598
|
+
frames
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function applyFg(text, color) {
|
|
603
|
+
if (!color) return text;
|
|
604
|
+
if (color.startsWith('#')) return chalk.hex(color)(text);
|
|
605
|
+
if (typeof chalk[color] === 'function') return chalk[color](text);
|
|
606
|
+
return text;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function applyBg(text, color) {
|
|
610
|
+
if (!color) return text;
|
|
611
|
+
if (color.startsWith('#')) return chalk.bgHex(color)(text);
|
|
612
|
+
const key = `bg${color[0].toUpperCase()}${color.slice(1)}`;
|
|
613
|
+
if (typeof chalk[key] === 'function') return chalk[key](text);
|
|
614
|
+
return text;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function drawFrame(frame, width, theme) {
|
|
618
|
+
const top = `+${'-'.repeat(width - 2)}+`;
|
|
619
|
+
const bottom = `+${'-'.repeat(width - 2)}+`;
|
|
620
|
+
const contentWidth = width - 4;
|
|
621
|
+
|
|
622
|
+
console.log(applyFg(top, theme.border));
|
|
623
|
+
|
|
624
|
+
for (let y = 0; y < frame.content.length; y += 1) {
|
|
625
|
+
const row = fitLine(frame.content[y] || '', contentWidth).padEnd(contentWidth, ' ');
|
|
626
|
+
let renderedRow = '';
|
|
627
|
+
|
|
628
|
+
for (let x = 0; x < row.length; x += 1) {
|
|
629
|
+
const char = row[x];
|
|
630
|
+
const key = `${x},${y}`;
|
|
631
|
+
const fgColorKey = frame.fgColors[key];
|
|
632
|
+
const bgColorKey = frame.bgColors[key];
|
|
633
|
+
|
|
634
|
+
const fgColor = fgColorKey
|
|
635
|
+
? (theme[fgColorKey] || fgColorKey)
|
|
636
|
+
: (theme.logo || 'white');
|
|
637
|
+
const bgColor = bgColorKey
|
|
638
|
+
? (theme[bgColorKey] || bgColorKey)
|
|
639
|
+
: undefined;
|
|
640
|
+
|
|
641
|
+
let styled = applyFg(char, fgColor);
|
|
642
|
+
if (bgColor) styled = applyBg(styled, bgColor);
|
|
643
|
+
renderedRow += styled;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const left = applyFg('| ', theme.border);
|
|
647
|
+
const right = applyFg(' |', theme.border);
|
|
648
|
+
console.log(left + renderedRow + right);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
console.log(applyFg(bottom, theme.border));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function animateBanner(options = {}) {
|
|
655
|
+
const {
|
|
656
|
+
hasDarkBackground = true,
|
|
657
|
+
autoPlay = true,
|
|
658
|
+
loop: _loop = DEFAULT_LOOP,
|
|
659
|
+
frameDelayMs,
|
|
660
|
+
frames = 16,
|
|
661
|
+
enabled = true
|
|
662
|
+
} = options;
|
|
663
|
+
|
|
664
|
+
const shouldAnimate =
|
|
665
|
+
enabled &&
|
|
666
|
+
autoPlay &&
|
|
667
|
+
process.stdout.isTTY &&
|
|
668
|
+
process.env.LLM_CHECKER_DISABLE_ANIMATION !== '1';
|
|
669
|
+
|
|
670
|
+
const frameDurationMs = frameDelayMs || Math.round(1000 / FRAMES_PER_SECOND);
|
|
671
|
+
const prepared = makeFrames({
|
|
672
|
+
frameCount: Math.max(1, frames),
|
|
673
|
+
hasDarkBackground,
|
|
674
|
+
frameDurationMs
|
|
675
|
+
});
|
|
676
|
+
const textBanner = loadTextBanner();
|
|
677
|
+
|
|
678
|
+
if (textBanner && textBanner.length > 0) {
|
|
679
|
+
if (!shouldAnimate) {
|
|
680
|
+
clearTerminal();
|
|
681
|
+
drawTextBanner(textBanner);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const textFrames = Math.max(10, Math.min(24, frames));
|
|
686
|
+
for (let frameIndex = 0; frameIndex < textFrames; frameIndex += 1) {
|
|
687
|
+
clearTerminal();
|
|
688
|
+
drawTextBanner(textBanner, { colorPhase: frameIndex });
|
|
689
|
+
await sleep(frameDurationMs);
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (!shouldAnimate || prepared.frames.length <= 1) {
|
|
695
|
+
clearTerminal();
|
|
696
|
+
drawFrame(prepared.frames[prepared.frames.length - 1], prepared.width, prepared.theme);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
for (const frame of prepared.frames) {
|
|
701
|
+
clearTerminal();
|
|
702
|
+
drawFrame(frame, prepared.width, prepared.theme);
|
|
703
|
+
await sleep(frame.duration);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function renderPersistentBanner(width = 74, options = {}) {
|
|
708
|
+
const textBanner = loadTextBanner();
|
|
709
|
+
if (textBanner && textBanner.length > 0) {
|
|
710
|
+
drawTextBanner(textBanner, options);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const prepared = makeFrames({ frameCount: 1, width });
|
|
715
|
+
drawFrame(prepared.frames[0], prepared.width, prepared.theme);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function renderCommandHeader(commandLabel) {
|
|
719
|
+
const label = String(commandLabel || 'command');
|
|
720
|
+
const line = '-'.repeat(Math.min(64, Math.max(28, label.length + 24)));
|
|
721
|
+
console.log(chalk.cyan.bold(`\nllm-checker | ${label}`));
|
|
722
|
+
console.log(chalk.gray(line));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
module.exports = {
|
|
726
|
+
animateBanner,
|
|
727
|
+
renderPersistentBanner,
|
|
728
|
+
renderCommandHeader,
|
|
729
|
+
__private: {
|
|
730
|
+
makeFrames,
|
|
731
|
+
drawFrame
|
|
732
|
+
}
|
|
733
|
+
};
|