@seflless/ghosttown 1.1.3 → 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/bin/ascii.js ADDED
@@ -0,0 +1,158 @@
1
+ import fs from 'fs';
2
+ import { PNG } from 'pngjs';
3
+
4
+ // Access sync API from PNG
5
+ const PNGSync = PNG.sync;
6
+
7
+ /**
8
+ * Converts RGB color to ANSI 256-color code.
9
+ * Uses the standard 256-color palette mapping.
10
+ */
11
+ function rgbToAnsi256(r, g, b) {
12
+ // Standard 16 colors (0-15) are handled specially
13
+ // Colors 16-231 are a 6x6x6 color cube
14
+ // Colors 232-255 are grayscale
15
+
16
+ // If color is very close to grayscale, use grayscale palette (232-255)
17
+ const gray = Math.round((r + g + b) / 3);
18
+ const grayDiff = Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
19
+
20
+ if (grayDiff < 10) {
21
+ // Use grayscale palette (232-255)
22
+ const grayIndex = Math.round((gray / 255) * 23);
23
+ return 232 + Math.max(0, Math.min(23, grayIndex));
24
+ }
25
+
26
+ // Use color cube (16-231)
27
+ // Each component is mapped to 0-5 (6 levels)
28
+ const rIndex = Math.round((r / 255) * 5);
29
+ const gIndex = Math.round((g / 255) * 5);
30
+ const bIndex = Math.round((b / 255) * 5);
31
+
32
+ // Color cube formula: 16 + 36*r + 6*g + b
33
+ return 16 + 36 * rIndex + 6 * gIndex + bIndex;
34
+ }
35
+
36
+ /**
37
+ * Generates ANSI escape code for foreground color using 256-color palette.
38
+ */
39
+ function ansiColorCode(color256) {
40
+ return `\x1b[38;5;${color256}m`;
41
+ }
42
+
43
+ /**
44
+ * Resets ANSI color formatting.
45
+ */
46
+ function ansiReset() {
47
+ return '\x1b[0m';
48
+ }
49
+
50
+ /**
51
+ * Solid block character for opaque pixels.
52
+ * Using FULL BLOCK (U+2588) which fills the character cell.
53
+ */
54
+ const SOLID_CHAR = '█';
55
+
56
+ /**
57
+ * Converts an image file to ASCII art with ANSI colors.
58
+ *
59
+ * @param {string} imagePath - Path to the image file (PNG supported)
60
+ * @param {Object} options - Optional configuration
61
+ * @param {number} [options.maxWidth=80] - Maximum width in characters
62
+ * @param {number} [options.maxHeight=24] - Maximum height in characters
63
+ * @param {number} [options.transparentThreshold=128] - Alpha threshold below which pixel is considered transparent
64
+ * @returns {Promise<string>} ASCII art string with ANSI color codes
65
+ */
66
+ export async function asciiArt(imagePath, options = {}) {
67
+ const {
68
+ maxWidth = 80,
69
+ maxHeight = 24,
70
+ transparentThreshold = 128, // Alpha values below this are considered transparent
71
+ } = options;
72
+
73
+ // Read image file
74
+ const imageData = fs.readFileSync(imagePath);
75
+
76
+ // Parse PNG
77
+ const png = PNGSync.read(imageData);
78
+ const { width, height, data } = png;
79
+
80
+ // Calculate scaling to fit within max dimensions while preserving aspect ratio
81
+ const scaleX = maxWidth / width;
82
+ const scaleY = maxHeight / height;
83
+ const scale = Math.min(scaleX, scaleY, 1); // Don't upscale
84
+
85
+ const outputWidth = Math.max(1, Math.floor(width * scale));
86
+ const outputHeight = Math.max(1, Math.floor(height * scale));
87
+
88
+ const result = [];
89
+
90
+ // Process each output row
91
+ for (let outY = 0; outY < outputHeight; outY++) {
92
+ let line = '';
93
+ let lastColor = null;
94
+
95
+ // Process each output column
96
+ for (let outX = 0; outX < outputWidth; outX++) {
97
+ // Map output coordinates back to source image
98
+ const srcX = Math.floor((outX / outputWidth) * width);
99
+ const srcY = Math.floor((outY / outputHeight) * height);
100
+
101
+ // Get pixel data (RGBA format)
102
+ const idx = (srcY * width + srcX) * 4;
103
+ const r = data[idx];
104
+ const g = data[idx + 1];
105
+ const b = data[idx + 2];
106
+ const a = data[idx + 3];
107
+
108
+ // Handle transparent pixels - render as space
109
+ if (a < transparentThreshold) {
110
+ line += ' ';
111
+ lastColor = null;
112
+ continue;
113
+ }
114
+
115
+ // Solid pixels - use block character with color
116
+ // Convert RGB to ANSI 256-color
117
+ const color256 = rgbToAnsi256(r, g, b);
118
+
119
+ // Only add color code if it changed from previous pixel
120
+ if (lastColor !== color256) {
121
+ if (lastColor !== null) {
122
+ line += ansiReset();
123
+ }
124
+ line += ansiColorCode(color256);
125
+ lastColor = color256;
126
+ }
127
+
128
+ line += SOLID_CHAR;
129
+ }
130
+
131
+ // Reset color at end of line
132
+ if (lastColor !== null) {
133
+ line += ansiReset();
134
+ }
135
+
136
+ result.push(line);
137
+ }
138
+
139
+ // Ensure we always end with a reset to clear any color state
140
+ return result.join('\n') + ansiReset();
141
+ }
142
+
143
+ // If running directly, execute the function
144
+ const isMainModule =
145
+ import.meta.url === `file://${process.argv[1]}` ||
146
+ process.argv[1]?.endsWith('/ascii.js');
147
+
148
+ if (isMainModule) {
149
+ const imagePath = process.argv[2] || 'demo/images/ghosts.png';
150
+ asciiArt(imagePath)
151
+ .then((art) => {
152
+ console.log(art);
153
+ })
154
+ .catch((err) => {
155
+ console.error('Error generating ASCII art:', err);
156
+ process.exit(1);
157
+ });
158
+ }
package/bin/ghosttown.js CHANGED
@@ -29,6 +29,8 @@ import { fileURLToPath } from 'url';
29
29
  import pty from '@lydell/node-pty';
30
30
  // WebSocket server
31
31
  import { WebSocketServer } from 'ws';
32
+ // ASCII art generator
33
+ import { asciiArt } from './ascii.js';
32
34
 
33
35
  const __filename = fileURLToPath(import.meta.url);
34
36
  const __dirname = path.dirname(__filename);
@@ -643,36 +645,39 @@ function getLocalIPs() {
643
645
 
644
646
  function printBanner(url) {
645
647
  const localIPs = getLocalIPs();
646
- // console.log('\n' + '═'.repeat(50));
647
- console.log('');
648
- // console.log(' 👻 ghosttown');
649
- // console.log(''.repeat(50));
650
- const labels = [
651
- [' Open:', url],
652
- [' Network:', ''],
653
- [' Shell:', getShell()],
654
- [' Home:', homedir()],
655
- ];
648
+ // ANSI color codes
649
+ const RESET = '\x1b[0m';
650
+ const CYAN = '\x1b[36m'; // Labels
651
+ const BEIGE = '\x1b[38;2;255;220;150m'; // Warm yellow/beige for values (RGB: 255,220,150)
652
+ const DIM = '\x1b[2m'; // Dimmed text
656
653
 
657
- console.log(labels[0].join(' '));
654
+ console.log('');
658
655
 
659
- const network = [labels[1].join(' ')];
656
+ // Open: URL
657
+ console.log(` ${CYAN}Open:${RESET} ${BEIGE}${url}${RESET}`);
660
658
 
659
+ // Network: URLs
660
+ const network = [` ${CYAN}Network: ${RESET}`];
661
661
  if (localIPs.length > 0) {
662
662
  let networkCount = 0;
663
663
  for (const ip of localIPs) {
664
664
  networkCount++;
665
665
  const spaces = networkCount !== 1 ? ' ' : '';
666
- network.push(`${spaces}http://${ip}:${HTTP_PORT}\n`);
666
+ network.push(`${spaces}${BEIGE}http://${ip}:${HTTP_PORT}${RESET}\n`);
667
667
  }
668
668
  }
669
- console.log(`\n${network.join('')}`);
670
- // Shell
671
- console.log(labels[2].join(' '));
672
- // Home
673
- console.log(labels[3].join(' '));
669
+ console.log(`\n${network.join('')} `);
670
+
671
+ // Shell: path
672
+ console.log(` ${CYAN}Shell:${RESET} ${BEIGE}${getShell()}${RESET}`);
673
+
674
+ console.log('');
675
+
676
+ // Home: path
677
+ console.log(` ${CYAN}Home:${RESET} ${BEIGE}${homedir()}${RESET}`);
678
+
674
679
  console.log('');
675
- console.log(' Press Ctrl+C to stop.\n');
680
+ console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
676
681
  }
677
682
 
678
683
  process.on('SIGINT', () => {
@@ -685,6 +690,21 @@ process.on('SIGINT', () => {
685
690
  process.exit(0);
686
691
  });
687
692
 
688
- httpServer.listen(HTTP_PORT, '0.0.0.0', () => {
693
+ httpServer.listen(HTTP_PORT, '0.0.0.0', async () => {
694
+ // Display ASCII art banner
695
+ try {
696
+ const imagePath = path.join(__dirname, '..', 'demo', 'images', 'ghosts.png');
697
+ if (fs.existsSync(imagePath)) {
698
+ // Welcome text with orange/yellow color (bright yellow, bold)
699
+ console.log('\n \x1b[1;93mWelcome to Ghosttown!\x1b[0m\n');
700
+ const art = await asciiArt(imagePath, { maxWidth: 80, maxHeight: 20 });
701
+ console.log(art);
702
+ console.log('');
703
+ }
704
+ } catch (err) {
705
+ // Silently fail if ASCII art can't be displayed
706
+ // This allows the server to start even if the image is missing
707
+ }
708
+
689
709
  printBanner(`http://localhost:${HTTP_PORT}`);
690
710
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seflless/ghosttown",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Web-based terminal emulator using Ghostty's VT100 parser via WebAssembly",
5
5
  "type": "module",
6
6
  "main": "./dist/ghostty-web.umd.cjs",