@seflless/ghosttown 1.1.3 → 1.2.0

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.ts ADDED
@@ -0,0 +1,158 @@
1
+ import fs from 'fs';
2
+ import { PNG } from 'pngjs';
3
+
4
+ // Type assertion for sync API (pngjs types may not include sync)
5
+ const PNGSync = (PNG as any).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: number, g: number, b: number): number {
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: number): string {
40
+ return `\x1b[38;5;${color256}m`;
41
+ }
42
+
43
+ /**
44
+ * Resets ANSI color formatting.
45
+ */
46
+ function ansiReset(): string {
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 imagePath - Path to the image file (PNG supported)
60
+ * @param options - Optional configuration
61
+ * @returns ASCII art string with ANSI color codes
62
+ */
63
+ export async function asciiArt(
64
+ imagePath: string,
65
+ options: {
66
+ maxWidth?: number;
67
+ maxHeight?: number;
68
+ transparentThreshold?: number; // Alpha threshold below which pixel is considered transparent
69
+ } = {}
70
+ ): Promise<string> {
71
+ const {
72
+ maxWidth = 80,
73
+ maxHeight = 24,
74
+ transparentThreshold = 128, // Alpha values below this are considered transparent
75
+ } = options;
76
+
77
+ // Read image file
78
+ const imageData = fs.readFileSync(imagePath);
79
+
80
+ // Parse PNG
81
+ const png = PNGSync.read(imageData);
82
+ const { width, height, data } = png;
83
+
84
+ // Calculate scaling to fit within max dimensions while preserving aspect ratio
85
+ const scaleX = maxWidth / width;
86
+ const scaleY = maxHeight / height;
87
+ const scale = Math.min(scaleX, scaleY, 1); // Don't upscale
88
+
89
+ const outputWidth = Math.max(1, Math.floor(width * scale));
90
+ const outputHeight = Math.max(1, Math.floor(height * scale));
91
+
92
+ const result: string[] = [];
93
+
94
+ // Process each output row
95
+ for (let outY = 0; outY < outputHeight; outY++) {
96
+ let line = '';
97
+ let lastColor: number | null = null;
98
+
99
+ // Process each output column
100
+ for (let outX = 0; outX < outputWidth; outX++) {
101
+ // Map output coordinates back to source image
102
+ const srcX = Math.floor((outX / outputWidth) * width);
103
+ const srcY = Math.floor((outY / outputHeight) * height);
104
+
105
+ // Get pixel data (RGBA format)
106
+ const idx = (srcY * width + srcX) * 4;
107
+ const r = data[idx];
108
+ const g = data[idx + 1];
109
+ const b = data[idx + 2];
110
+ const a = data[idx + 3];
111
+
112
+ // Handle transparent pixels - render as space
113
+ if (a < transparentThreshold) {
114
+ line += ' ';
115
+ lastColor = null;
116
+ continue;
117
+ }
118
+
119
+ // Solid pixels - use block character with color
120
+ // Convert RGB to ANSI 256-color
121
+ const color256 = rgbToAnsi256(r, g, b);
122
+
123
+ // Only add color code if it changed from previous pixel
124
+ if (lastColor !== color256) {
125
+ if (lastColor !== null) {
126
+ line += ansiReset();
127
+ }
128
+ line += ansiColorCode(color256);
129
+ lastColor = color256;
130
+ }
131
+
132
+ line += SOLID_CHAR;
133
+ }
134
+
135
+ // Reset color at end of line
136
+ if (lastColor !== null) {
137
+ line += ansiReset();
138
+ }
139
+
140
+ result.push(line);
141
+ }
142
+
143
+ // Ensure we always end with a reset to clear any color state
144
+ return result.join('\n') + ansiReset();
145
+ }
146
+
147
+ // If running directly, execute the function
148
+ if (import.meta.main) {
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.ts';
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.0",
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",