gbos 1.1.9 → 1.2.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/package.json +3 -2
- package/src/cli.js +6 -0
- package/src/commands/connect.js +205 -71
- package/src/commands/logo.js +20 -0
- package/src/lib/api.js +5 -0
- package/src/lib/display.js +187 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gbos",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "GBOS - Command line interface for GBOS services",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"commander": "^12.1.0",
|
|
42
|
-
"pngjs": "^7.0.0"
|
|
42
|
+
"pngjs": "^7.0.0",
|
|
43
|
+
"terminal-image": "^4.2.0"
|
|
43
44
|
}
|
|
44
45
|
}
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ const program = new Command();
|
|
|
6
6
|
const authCommand = require('./commands/auth');
|
|
7
7
|
const connectCommand = require('./commands/connect');
|
|
8
8
|
const logoutCommand = require('./commands/logout');
|
|
9
|
+
const logoCommand = require('./commands/logo');
|
|
9
10
|
const config = require('./lib/config');
|
|
10
11
|
|
|
11
12
|
const VERSION = require('../package.json').version;
|
|
@@ -117,6 +118,11 @@ program
|
|
|
117
118
|
.option('-a, --all', 'Clear all stored data including machine ID')
|
|
118
119
|
.action(logoutCommand);
|
|
119
120
|
|
|
121
|
+
program
|
|
122
|
+
.command('logo')
|
|
123
|
+
.description('Print the GBOS logo image')
|
|
124
|
+
.action(logoCommand);
|
|
125
|
+
|
|
120
126
|
program
|
|
121
127
|
.command('help [command]')
|
|
122
128
|
.description('Display help for a specific command')
|
package/src/commands/connect.js
CHANGED
|
@@ -1,45 +1,114 @@
|
|
|
1
1
|
const api = require('../lib/api');
|
|
2
2
|
const config = require('../lib/config');
|
|
3
3
|
const { checkForUpdates } = require('../lib/version');
|
|
4
|
-
const { displayConnectSuccess, displayMessageBox } = require('../lib/display');
|
|
4
|
+
const { displayConnectSuccess, displayMessageBox, colors } = require('../lib/display');
|
|
5
5
|
const readline = require('readline');
|
|
6
|
-
const path = require('path');
|
|
7
6
|
const { execSync } = require('child_process');
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
const ESC = '\x1b';
|
|
9
|
+
const RESET = `${ESC}[0m`;
|
|
10
|
+
const BOLD = `${ESC}[1m`;
|
|
11
|
+
const DIM = `${ESC}[2m`;
|
|
12
|
+
const PURPLE = `${ESC}[38;5;99m`;
|
|
13
|
+
const WHITE = `${ESC}[37m`;
|
|
14
|
+
const CYAN = `${ESC}[36m`;
|
|
15
|
+
|
|
16
|
+
// Interactive arrow key selector
|
|
17
|
+
async function selectWithArrows(title, options, displayFn) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let selectedIndex = 0;
|
|
20
|
+
const stdin = process.stdin;
|
|
21
|
+
const stdout = process.stdout;
|
|
22
|
+
|
|
23
|
+
// Save cursor position and hide cursor
|
|
24
|
+
stdout.write(`${ESC}[?25l`);
|
|
25
|
+
|
|
26
|
+
function render() {
|
|
27
|
+
// Move cursor to start and clear
|
|
28
|
+
stdout.write(`${ESC}[${options.length + 3}A${ESC}[J`);
|
|
29
|
+
|
|
30
|
+
console.log(`\n${PURPLE}${title}${RESET}\n`);
|
|
31
|
+
|
|
32
|
+
options.forEach((opt, index) => {
|
|
33
|
+
const isSelected = index === selectedIndex;
|
|
34
|
+
const prefix = isSelected ? `${CYAN}❯${RESET}` : ' ';
|
|
35
|
+
const text = displayFn ? displayFn(opt, isSelected) : opt.name;
|
|
15
36
|
|
|
16
|
-
|
|
37
|
+
if (isSelected) {
|
|
38
|
+
console.log(` ${prefix} ${BOLD}${WHITE}${text}${RESET}`);
|
|
39
|
+
} else {
|
|
40
|
+
console.log(` ${prefix} ${DIM}${text}${RESET}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
17
43
|
|
|
18
|
-
|
|
19
|
-
const status = opt.status ? ` [${opt.status}]` : '';
|
|
20
|
-
const connected = opt.is_connected ? ' (connected by another user)' : '';
|
|
21
|
-
console.log(` ${index + 1}. ${opt.name}${status}${connected}`);
|
|
22
|
-
if (opt.description) {
|
|
23
|
-
console.log(` ${opt.description}`);
|
|
44
|
+
console.log(`\n ${DIM}↑/↓ to navigate, Enter to select, q to cancel${RESET}`);
|
|
24
45
|
}
|
|
25
|
-
});
|
|
26
46
|
|
|
27
|
-
|
|
47
|
+
// Initial render
|
|
48
|
+
console.log(`\n${PURPLE}${title}${RESET}\n`);
|
|
49
|
+
options.forEach((opt, index) => {
|
|
50
|
+
const isSelected = index === selectedIndex;
|
|
51
|
+
const prefix = isSelected ? `${CYAN}❯${RESET}` : ' ';
|
|
52
|
+
const text = displayFn ? displayFn(opt, isSelected) : opt.name;
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
54
|
+
if (isSelected) {
|
|
55
|
+
console.log(` ${prefix} ${BOLD}${WHITE}${text}${RESET}`);
|
|
56
|
+
} else {
|
|
57
|
+
console.log(` ${prefix} ${DIM}${text}${RESET}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
console.log(`\n ${DIM}↑/↓ to navigate, Enter to select, q to cancel${RESET}`);
|
|
61
|
+
|
|
62
|
+
// Enable raw mode
|
|
63
|
+
if (stdin.isTTY) {
|
|
64
|
+
stdin.setRawMode(true);
|
|
65
|
+
}
|
|
66
|
+
stdin.resume();
|
|
67
|
+
stdin.setEncoding('utf8');
|
|
68
|
+
|
|
69
|
+
function cleanup() {
|
|
70
|
+
if (stdin.isTTY) {
|
|
71
|
+
stdin.setRawMode(false);
|
|
72
|
+
}
|
|
73
|
+
stdin.removeListener('data', onKeypress);
|
|
74
|
+
// Show cursor
|
|
75
|
+
stdout.write(`${ESC}[?25h`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onKeypress(key) {
|
|
79
|
+
// Ctrl+C
|
|
80
|
+
if (key === '\u0003') {
|
|
81
|
+
cleanup();
|
|
82
|
+
process.exit();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// q or Q to quit
|
|
86
|
+
if (key === 'q' || key === 'Q') {
|
|
87
|
+
cleanup();
|
|
33
88
|
resolve(null);
|
|
34
89
|
return;
|
|
35
90
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
resolve(
|
|
91
|
+
|
|
92
|
+
// Enter
|
|
93
|
+
if (key === '\r' || key === '\n') {
|
|
94
|
+
cleanup();
|
|
95
|
+
resolve(options[selectedIndex]);
|
|
96
|
+
return;
|
|
41
97
|
}
|
|
42
|
-
|
|
98
|
+
|
|
99
|
+
// Arrow keys (escape sequences)
|
|
100
|
+
if (key === `${ESC}[A` || key === 'k') {
|
|
101
|
+
// Up
|
|
102
|
+
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1;
|
|
103
|
+
render();
|
|
104
|
+
} else if (key === `${ESC}[B` || key === 'j') {
|
|
105
|
+
// Down
|
|
106
|
+
selectedIndex = selectedIndex < options.length - 1 ? selectedIndex + 1 : 0;
|
|
107
|
+
render();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stdin.on('data', onKeypress);
|
|
43
112
|
});
|
|
44
113
|
}
|
|
45
114
|
|
|
@@ -80,75 +149,140 @@ async function connectCommand(options) {
|
|
|
80
149
|
return;
|
|
81
150
|
}
|
|
82
151
|
|
|
83
|
-
console.log('\nFetching available
|
|
152
|
+
console.log('\nFetching available applications...\n');
|
|
153
|
+
|
|
154
|
+
// Try to fetch applications directly first
|
|
155
|
+
let appOptions = [];
|
|
156
|
+
let nodesByApp = {};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
// Try the applications endpoint first
|
|
160
|
+
const appsResponse = await api.listApplications();
|
|
161
|
+
const applications = appsResponse.data || [];
|
|
162
|
+
|
|
163
|
+
if (applications.length > 0) {
|
|
164
|
+
appOptions = applications.map((app) => ({
|
|
165
|
+
id: app.id,
|
|
166
|
+
name: app.name || `Application ${app.id}`,
|
|
167
|
+
description: app.description,
|
|
168
|
+
nodeCount: app.nodes_count || app.nodesCount || '?',
|
|
169
|
+
application: app,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Applications endpoint not available, fall back to deriving from nodes
|
|
174
|
+
if (process.env.DEBUG) {
|
|
175
|
+
console.log('Note: /cli/applications endpoint not available, falling back to nodes');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If no applications from direct endpoint, derive from nodes
|
|
180
|
+
if (appOptions.length === 0) {
|
|
181
|
+
const nodesResponse = await api.listNodes();
|
|
182
|
+
const nodes = nodesResponse.data || [];
|
|
183
|
+
|
|
184
|
+
if (nodes.length === 0) {
|
|
185
|
+
displayMessageBox(
|
|
186
|
+
'No Nodes Available',
|
|
187
|
+
'No development nodes available. Please create a development node in the GBOS web interface.',
|
|
188
|
+
'warning'
|
|
189
|
+
);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
84
192
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
193
|
+
// Group nodes by application
|
|
194
|
+
nodes.forEach((node) => {
|
|
195
|
+
const appId = node.application_id || 'unassigned';
|
|
196
|
+
if (nodesByApp[appId] === undefined) {
|
|
197
|
+
nodesByApp[appId] = {
|
|
198
|
+
application: node.application,
|
|
199
|
+
nodes: [],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
nodesByApp[appId].nodes.push(node);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const appIds = Object.keys(nodesByApp);
|
|
206
|
+
appOptions = appIds.map((appId) => ({
|
|
207
|
+
id: appId,
|
|
208
|
+
name: nodesByApp[appId].application?.name || `Application ${appId}`,
|
|
209
|
+
nodeCount: nodesByApp[appId].nodes.length,
|
|
210
|
+
application: nodesByApp[appId].application,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
88
213
|
|
|
89
|
-
if (
|
|
214
|
+
if (appOptions.length === 0) {
|
|
90
215
|
displayMessageBox(
|
|
91
|
-
'No
|
|
92
|
-
'No
|
|
216
|
+
'No Applications Available',
|
|
217
|
+
'No applications found. Please create an application in the GBOS web interface.',
|
|
93
218
|
'warning'
|
|
94
219
|
);
|
|
95
220
|
process.exit(1);
|
|
96
221
|
}
|
|
97
222
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
application: node.application,
|
|
105
|
-
nodes: [],
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
nodesByApp[appId].nodes.push(node);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// If multiple applications, let user select one first
|
|
112
|
-
let selectedApp = null;
|
|
113
|
-
const appIds = Object.keys(nodesByApp);
|
|
223
|
+
// Always show application selection (even if only one)
|
|
224
|
+
const selectedApp = await selectWithArrows(
|
|
225
|
+
'Select an application:',
|
|
226
|
+
appOptions,
|
|
227
|
+
(opt) => `${opt.name} ${DIM}(${opt.nodeCount} node${opt.nodeCount > 1 ? 's' : ''})${RESET}`
|
|
228
|
+
);
|
|
114
229
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
description: `${nodesByApp[appId].nodes.length} node(s) available`,
|
|
120
|
-
}));
|
|
230
|
+
if (!selectedApp) {
|
|
231
|
+
console.log('\nConnection cancelled.\n');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
121
234
|
|
|
122
|
-
|
|
235
|
+
// Get nodes for selected application
|
|
236
|
+
let appNodes;
|
|
237
|
+
let selectedApplication = selectedApp.application;
|
|
123
238
|
|
|
124
|
-
|
|
125
|
-
|
|
239
|
+
if (nodesByApp[selectedApp.id]) {
|
|
240
|
+
// We already have nodes from the fallback path
|
|
241
|
+
appNodes = nodesByApp[selectedApp.id].nodes;
|
|
242
|
+
selectedApplication = nodesByApp[selectedApp.id].application || selectedApp.application;
|
|
243
|
+
} else {
|
|
244
|
+
// Fetch nodes for the selected application
|
|
245
|
+
console.log(`\nFetching nodes for ${selectedApp.name}...\n`);
|
|
246
|
+
const nodesResponse = await api.listNodes(selectedApp.id);
|
|
247
|
+
appNodes = nodesResponse.data || [];
|
|
248
|
+
|
|
249
|
+
if (appNodes.length === 0) {
|
|
250
|
+
displayMessageBox(
|
|
251
|
+
'No Nodes Available',
|
|
252
|
+
`No development nodes found for "${selectedApp.name}". Please create a development node in the GBOS web interface.`,
|
|
253
|
+
'warning'
|
|
254
|
+
);
|
|
126
255
|
return;
|
|
127
256
|
}
|
|
128
|
-
} else {
|
|
129
|
-
selectedApp = { id: appIds[0] };
|
|
130
257
|
}
|
|
131
258
|
|
|
132
|
-
//
|
|
133
|
-
const appNodes = nodesByApp[selectedApp.id].nodes;
|
|
134
|
-
const selectedApplication = nodesByApp[selectedApp.id].application;
|
|
135
|
-
|
|
136
|
-
// Let user select a node
|
|
259
|
+
// Build node options
|
|
137
260
|
const nodeOptions = appNodes.map((node) => ({
|
|
138
261
|
...node,
|
|
139
|
-
|
|
140
|
-
|
|
262
|
+
displayName: node.name,
|
|
263
|
+
nodeType: node.node_type || '',
|
|
264
|
+
isBusy: node.is_connected && node.active_connection,
|
|
141
265
|
}));
|
|
142
266
|
|
|
143
|
-
|
|
267
|
+
// Select a node
|
|
268
|
+
const selectedNode = await selectWithArrows(
|
|
269
|
+
'Select a development node:',
|
|
270
|
+
nodeOptions,
|
|
271
|
+
(opt) => {
|
|
272
|
+
let text = opt.displayName;
|
|
273
|
+
if (opt.nodeType) text += ` ${DIM}[${opt.nodeType}]${RESET}`;
|
|
274
|
+
if (opt.isBusy) text += ` ${DIM}(busy)${RESET}`;
|
|
275
|
+
return text;
|
|
276
|
+
}
|
|
277
|
+
);
|
|
144
278
|
|
|
145
279
|
if (!selectedNode) {
|
|
146
|
-
console.log('
|
|
280
|
+
console.log('\nConnection cancelled.\n');
|
|
147
281
|
return;
|
|
148
282
|
}
|
|
149
283
|
|
|
150
284
|
// Check if node is busy
|
|
151
|
-
if (selectedNode.
|
|
285
|
+
if (selectedNode.isBusy) {
|
|
152
286
|
displayMessageBox(
|
|
153
287
|
'Node Busy',
|
|
154
288
|
`Node "${selectedNode.name}" is already connected by another user. Please select a different node.`,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { displayImage, getTerminalWidth } = require('../lib/display');
|
|
3
|
+
|
|
4
|
+
async function logoCommand() {
|
|
5
|
+
const logoPath = path.join(__dirname, '../../images/logo-2.png');
|
|
6
|
+
const terminalWidth = getTerminalWidth();
|
|
7
|
+
const targetWidth = Math.min(120, Math.max(40, terminalWidth - 4));
|
|
8
|
+
|
|
9
|
+
await displayImage(logoPath, {
|
|
10
|
+
width: targetWidth,
|
|
11
|
+
fallbackWidth: targetWidth,
|
|
12
|
+
fallbackHeight: null,
|
|
13
|
+
sharp: true,
|
|
14
|
+
crop: true,
|
|
15
|
+
alphaThreshold: 220,
|
|
16
|
+
cropAlphaThreshold: 220,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = logoCommand;
|
package/src/lib/api.js
CHANGED
|
@@ -81,6 +81,11 @@ class GbosApiClient {
|
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Application endpoints
|
|
85
|
+
async listApplications() {
|
|
86
|
+
return this.request('/cli/applications', { method: 'GET' });
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
// Node endpoints
|
|
85
90
|
async listNodes(applicationId = null) {
|
|
86
91
|
let endpoint = '/cli/nodes';
|
package/src/lib/display.js
CHANGED
|
@@ -57,15 +57,18 @@ function samplePixel(png, x, y) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Check if pixel is transparent or white (background)
|
|
60
|
-
function isBackground(pixel) {
|
|
61
|
-
|
|
60
|
+
function isBackground(pixel, options = {}) {
|
|
61
|
+
const alphaThreshold = options.alphaThreshold ?? 50;
|
|
62
|
+
const backgroundLuminance = options.backgroundLuminance ?? 240;
|
|
63
|
+
return pixel.a < alphaThreshold ||
|
|
64
|
+
(pixel.r > backgroundLuminance && pixel.g > backgroundLuminance && pixel.b > backgroundLuminance);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// Shading characters for smooth edges (from light to full)
|
|
65
68
|
const SHADE_CHARS = [' ', '░', '▒', '▓', '█'];
|
|
66
69
|
|
|
67
70
|
// Get average alpha/coverage for a region (for anti-aliasing)
|
|
68
|
-
function getRegionCoverage(png, startX, startY, width, height) {
|
|
71
|
+
function getRegionCoverage(png, startX, startY, width, height, backgroundOptions = {}) {
|
|
69
72
|
let totalAlpha = 0;
|
|
70
73
|
let totalR = 0, totalG = 0, totalB = 0;
|
|
71
74
|
let samples = 0;
|
|
@@ -73,7 +76,7 @@ function getRegionCoverage(png, startX, startY, width, height) {
|
|
|
73
76
|
for (let y = startY; y < startY + height; y++) {
|
|
74
77
|
for (let x = startX; x < startX + width; x++) {
|
|
75
78
|
const pixel = samplePixel(png, x, y);
|
|
76
|
-
if (!isBackground(pixel)) {
|
|
79
|
+
if (!isBackground(pixel, backgroundOptions)) {
|
|
77
80
|
totalAlpha += pixel.a;
|
|
78
81
|
totalR += pixel.r;
|
|
79
82
|
totalG += pixel.g;
|
|
@@ -94,30 +97,111 @@ function getRegionCoverage(png, startX, startY, width, height) {
|
|
|
94
97
|
};
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
function cropPng(png, alphaThreshold = 1) {
|
|
101
|
+
let minX = png.width;
|
|
102
|
+
let minY = png.height;
|
|
103
|
+
let maxX = -1;
|
|
104
|
+
let maxY = -1;
|
|
105
|
+
|
|
106
|
+
for (let y = 0; y < png.height; y++) {
|
|
107
|
+
for (let x = 0; x < png.width; x++) {
|
|
108
|
+
const idx = (png.width * y + x) << 2;
|
|
109
|
+
if (png.data[idx + 3] >= alphaThreshold) {
|
|
110
|
+
minX = Math.min(minX, x);
|
|
111
|
+
minY = Math.min(minY, y);
|
|
112
|
+
maxX = Math.max(maxX, x);
|
|
113
|
+
maxY = Math.max(maxY, y);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (maxX < minX || maxY < minY) return png;
|
|
119
|
+
|
|
120
|
+
const width = maxX - minX + 1;
|
|
121
|
+
const height = maxY - minY + 1;
|
|
122
|
+
const cropped = new PNG({ width, height });
|
|
123
|
+
|
|
124
|
+
for (let y = 0; y < height; y++) {
|
|
125
|
+
const srcStart = ((minY + y) * png.width + minX) << 2;
|
|
126
|
+
const srcEnd = srcStart + (width << 2);
|
|
127
|
+
const destStart = (width * y) << 2;
|
|
128
|
+
png.data.copy(cropped.data, destStart, srcStart, srcEnd);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return cropped;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Convert PNG to true-color pixel art
|
|
135
|
+
// Uses shading characters for anti-aliasing on edges when sampling coverage
|
|
136
|
+
function imageToPixels(imagePath, targetWidth = 24, targetHeight = 5, options = {}) {
|
|
137
|
+
const {
|
|
138
|
+
alphaThreshold = 50,
|
|
139
|
+
backgroundLuminance = 240,
|
|
140
|
+
sampleMode = 'coverage',
|
|
141
|
+
crop = false,
|
|
142
|
+
cropAlphaThreshold = 1,
|
|
143
|
+
} = options;
|
|
144
|
+
|
|
100
145
|
try {
|
|
101
146
|
const data = fs.readFileSync(imagePath);
|
|
102
|
-
|
|
147
|
+
let png = PNG.sync.read(data);
|
|
148
|
+
if (crop) {
|
|
149
|
+
png = cropPng(png, cropAlphaThreshold);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const resolvedWidth = Math.max(1, Math.round(targetWidth));
|
|
153
|
+
const resolvedHeight = targetHeight == null
|
|
154
|
+
? Math.max(1, Math.round((resolvedWidth * png.height) / (png.width * 2)))
|
|
155
|
+
: Math.max(1, Math.round(targetHeight));
|
|
103
156
|
|
|
104
157
|
// Each row of output = 2 rows of pixels (using half-blocks)
|
|
105
|
-
const pixelRows =
|
|
106
|
-
const cellWidth = png.width /
|
|
158
|
+
const pixelRows = resolvedHeight * 2;
|
|
159
|
+
const cellWidth = png.width / resolvedWidth;
|
|
107
160
|
const cellHeight = png.height / pixelRows;
|
|
161
|
+
const backgroundOptions = { alphaThreshold, backgroundLuminance };
|
|
108
162
|
|
|
109
163
|
const lines = [];
|
|
110
164
|
|
|
111
|
-
for (let row = 0; row <
|
|
165
|
+
for (let row = 0; row < resolvedHeight; row++) {
|
|
112
166
|
let line = '';
|
|
113
|
-
for (let col = 0; col <
|
|
167
|
+
for (let col = 0; col < resolvedWidth; col++) {
|
|
168
|
+
if (sampleMode === 'nearest') {
|
|
169
|
+
const topStartY = row * 2 * cellHeight;
|
|
170
|
+
const bottomStartY = (row * 2 + 1) * cellHeight;
|
|
171
|
+
const startX = col * cellWidth;
|
|
172
|
+
|
|
173
|
+
const topPixel = samplePixel(png, startX + cellWidth * 0.5, topStartY + cellHeight * 0.5);
|
|
174
|
+
const bottomPixel = samplePixel(png, startX + cellWidth * 0.5, bottomStartY + cellHeight * 0.5);
|
|
175
|
+
const topBg = isBackground(topPixel, backgroundOptions);
|
|
176
|
+
const bottomBg = isBackground(bottomPixel, backgroundOptions);
|
|
177
|
+
|
|
178
|
+
if (topBg && bottomBg) {
|
|
179
|
+
line += ' ';
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!topBg && !bottomBg) {
|
|
184
|
+
line += fg(topPixel.r, topPixel.g, topPixel.b) +
|
|
185
|
+
bg(bottomPixel.r, bottomPixel.g, bottomPixel.b) +
|
|
186
|
+
UPPER_HALF + RESET;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!topBg) {
|
|
191
|
+
line += fg(topPixel.r, topPixel.g, topPixel.b) + UPPER_HALF + RESET;
|
|
192
|
+
} else {
|
|
193
|
+
line += fg(bottomPixel.r, bottomPixel.g, bottomPixel.b) + LOWER_HALF + RESET;
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
114
198
|
// Get coverage for top and bottom halves of this cell
|
|
115
199
|
const topStartY = row * 2 * cellHeight;
|
|
116
200
|
const bottomStartY = (row * 2 + 1) * cellHeight;
|
|
117
201
|
const startX = col * cellWidth;
|
|
118
202
|
|
|
119
|
-
const topRegion = getRegionCoverage(png, startX, topStartY, cellWidth, cellHeight);
|
|
120
|
-
const bottomRegion = getRegionCoverage(png, startX, bottomStartY, cellWidth, cellHeight);
|
|
203
|
+
const topRegion = getRegionCoverage(png, startX, topStartY, cellWidth, cellHeight, backgroundOptions);
|
|
204
|
+
const bottomRegion = getRegionCoverage(png, startX, bottomStartY, cellWidth, cellHeight, backgroundOptions);
|
|
121
205
|
|
|
122
206
|
const topCov = topRegion.coverage;
|
|
123
207
|
const bottomCov = bottomRegion.coverage;
|
|
@@ -177,6 +261,92 @@ function imageToPixels(imagePath, targetWidth = 24, targetHeight = 5) {
|
|
|
177
261
|
}
|
|
178
262
|
}
|
|
179
263
|
|
|
264
|
+
function applyAlphaThreshold(png, alphaThreshold) {
|
|
265
|
+
if (!alphaThreshold) return png;
|
|
266
|
+
for (let i = 0; i < png.data.length; i += 4) {
|
|
267
|
+
if (png.data[i + 3] < alphaThreshold) {
|
|
268
|
+
png.data[i + 3] = 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return png;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function preparePngForRender(imagePath, options = {}) {
|
|
275
|
+
const { alphaThreshold, crop, cropAlphaThreshold } = options;
|
|
276
|
+
const data = fs.readFileSync(imagePath);
|
|
277
|
+
let png = PNG.sync.read(data);
|
|
278
|
+
if (alphaThreshold) {
|
|
279
|
+
png = applyAlphaThreshold(png, alphaThreshold);
|
|
280
|
+
}
|
|
281
|
+
if (crop) {
|
|
282
|
+
png = cropPng(png, cropAlphaThreshold ?? alphaThreshold ?? 1);
|
|
283
|
+
}
|
|
284
|
+
return PNG.sync.write(png);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function displayImage(imagePath, options = {}) {
|
|
288
|
+
const fallbackWidth = options.fallbackWidth === undefined ? 40 : options.fallbackWidth;
|
|
289
|
+
const fallbackHeight = options.fallbackHeight === undefined ? 12 : options.fallbackHeight;
|
|
290
|
+
const sharp = options.sharp ?? false;
|
|
291
|
+
const alphaThreshold = options.alphaThreshold ?? 50;
|
|
292
|
+
const crop = options.crop ?? false;
|
|
293
|
+
const cropAlphaThreshold = options.cropAlphaThreshold;
|
|
294
|
+
const backgroundLuminance = options.backgroundLuminance ?? 240;
|
|
295
|
+
const useProcessedBuffer = crop || sharp;
|
|
296
|
+
const renderOptions = { ...options };
|
|
297
|
+
delete renderOptions.fallbackWidth;
|
|
298
|
+
delete renderOptions.fallbackHeight;
|
|
299
|
+
delete renderOptions.sharp;
|
|
300
|
+
delete renderOptions.alphaThreshold;
|
|
301
|
+
delete renderOptions.crop;
|
|
302
|
+
delete renderOptions.cropAlphaThreshold;
|
|
303
|
+
delete renderOptions.backgroundLuminance;
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
let supportsGraphics = false;
|
|
307
|
+
try {
|
|
308
|
+
const supportsTerminalGraphics = await import('supports-terminal-graphics');
|
|
309
|
+
const graphics = supportsTerminalGraphics.default || supportsTerminalGraphics;
|
|
310
|
+
supportsGraphics = Boolean(
|
|
311
|
+
graphics?.stdout?.kitty || graphics?.stdout?.iterm2 || graphics?.stdout?.sixel,
|
|
312
|
+
);
|
|
313
|
+
} catch {
|
|
314
|
+
supportsGraphics = false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (supportsGraphics) {
|
|
318
|
+
const terminalImage = await import('terminal-image');
|
|
319
|
+
const renderer = terminalImage.default || terminalImage;
|
|
320
|
+
const buffer = useProcessedBuffer
|
|
321
|
+
? preparePngForRender(imagePath, { alphaThreshold, crop, cropAlphaThreshold })
|
|
322
|
+
: null;
|
|
323
|
+
const output = buffer
|
|
324
|
+
? await renderer.buffer(buffer, renderOptions)
|
|
325
|
+
: await renderer.file(imagePath, renderOptions);
|
|
326
|
+
process.stdout.write(output);
|
|
327
|
+
if (!output.endsWith('\n')) process.stdout.write('\n');
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
// Fall through to ANSI fallback
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const fallbackLines = imageToPixels(imagePath, fallbackWidth, fallbackHeight, {
|
|
335
|
+
sampleMode: sharp ? 'nearest' : 'coverage',
|
|
336
|
+
alphaThreshold,
|
|
337
|
+
backgroundLuminance,
|
|
338
|
+
crop,
|
|
339
|
+
cropAlphaThreshold: cropAlphaThreshold ?? alphaThreshold,
|
|
340
|
+
});
|
|
341
|
+
if (!fallbackLines) {
|
|
342
|
+
throw new Error('Unable to render image.');
|
|
343
|
+
}
|
|
344
|
+
console.log('');
|
|
345
|
+
fallbackLines.forEach((line) => console.log(line));
|
|
346
|
+
console.log('');
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
180
350
|
|
|
181
351
|
// Fallback compact logo
|
|
182
352
|
const COMPACT_LOGO = [
|
|
@@ -187,7 +357,7 @@ const COMPACT_LOGO = [
|
|
|
187
357
|
|
|
188
358
|
// Display logo with connection details (Claude Code style - clean, minimal)
|
|
189
359
|
function displayLogoWithDetails(details = null) {
|
|
190
|
-
const logoPath = path.join(__dirname, '../../images/logo.png');
|
|
360
|
+
const logoPath = path.join(__dirname, '../../images/logo-2.png');
|
|
191
361
|
const version = require('../../package.json').version;
|
|
192
362
|
|
|
193
363
|
// Render logo at ~20 chars wide, 5 rows tall (smooth edges)
|
|
@@ -226,7 +396,7 @@ function displayLogo() {
|
|
|
226
396
|
}
|
|
227
397
|
|
|
228
398
|
function displayAuthSuccess(data) {
|
|
229
|
-
const logoPath = path.join(__dirname, '../../images/logo.png');
|
|
399
|
+
const logoPath = path.join(__dirname, '../../images/logo-2.png');
|
|
230
400
|
const version = require('../../package.json').version;
|
|
231
401
|
|
|
232
402
|
let logoLines = imageToPixels(logoPath, 20, 5);
|
|
@@ -300,6 +470,7 @@ module.exports = {
|
|
|
300
470
|
displayAuthSuccess,
|
|
301
471
|
displayConnectSuccess,
|
|
302
472
|
displayMessageBox,
|
|
473
|
+
displayImage,
|
|
303
474
|
imageToPixels,
|
|
304
475
|
getTerminalWidth,
|
|
305
476
|
};
|