@sanohiro/casty 0.5.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/lib/kitty.js ADDED
@@ -0,0 +1,117 @@
1
+ // Kitty graphics protocol output
2
+ //
3
+ // Protocol parameters:
4
+ // a=T : transmit and display
5
+ // a=d : delete image(s)
6
+ // f=100: PNG format
7
+ // t=f : file transfer (send file path as base64)
8
+ // t=d : inline transfer (send image data as base64)
9
+ // q=2 : suppress response (no OK/ERR from terminal)
10
+ // C=1 : no cursor movement (keep cursor position after display)
11
+ // i=N : image ID (replace existing image with same ID)
12
+ // m=0/1: chunk continuation (1=more chunks follow, 0=final chunk)
13
+ // d=A : delete all images (used with a=d)
14
+ //
15
+ // Two transfer modes:
16
+ // File transfer (t=f): fast, sends only the path (bcon, etc.)
17
+ // Inline (t=d): sends base64 data directly in 4096B chunks (Ghostty, kitty)
18
+ //
19
+ // ~/.casty/config.json transport setting:
20
+ // "auto" → file transfer for bcon, inline for others
21
+ // "file" → force file transfer
22
+ // "inline" → force inline
23
+
24
+ import { writeFileSync, unlinkSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { tmpdir } from 'node:os';
27
+ import { loadConfig } from './config.js';
28
+
29
+ const tmpFile = join(tmpdir(), `casty-frame-${process.pid}.png`);
30
+ const tmpPathB64 = Buffer.from(tmpFile).toString('base64');
31
+
32
+ // Detect transfer mode
33
+ function detectTransport() {
34
+ const config = loadConfig();
35
+ const setting = config.transport || 'auto';
36
+
37
+ if (setting === 'file') return 'file';
38
+ if (setting === 'inline') return 'inline';
39
+
40
+ // auto: bcon supports t=f
41
+ const termProg = process.env.TERM_PROGRAM || '';
42
+ if (/bcon/i.test(termProg)) return 'file';
43
+
44
+ return 'inline';
45
+ }
46
+
47
+ export const transport = detectTransport();
48
+
49
+ // Cursor to line 2 (line 1 is reserved for URL bar)
50
+ const CURSOR_HOME = '\x1b[2;1H';
51
+ export function cursorHome() {
52
+ process.stdout.write(CURSOR_HOME);
53
+ }
54
+
55
+ // Clear screen (also delete all Kitty images)
56
+ export function clearScreen() {
57
+ process.stdout.write('\x1b_Ga=d,d=A,q=2;\x1b\\\x1b[2J\x1b[H');
58
+ }
59
+
60
+ // Hide cursor
61
+ export function hideCursor() {
62
+ process.stdout.write('\x1b[?25l');
63
+ }
64
+
65
+ // Show cursor
66
+ export function showCursor() {
67
+ process.stdout.write('\x1b[?25h');
68
+ }
69
+
70
+ // Clean up temp file
71
+ export function cleanup() {
72
+ try { unlinkSync(tmpFile); } catch {}
73
+ }
74
+
75
+ // Frame deduplication — skip identical consecutive frames
76
+ let lastFrameData = '';
77
+
78
+ // File transfer mode (fast: sends only path)
79
+ // Prepends cursor-home to batch into a single write
80
+ function sendFrameFile(base64Data) {
81
+ if (base64Data.length === lastFrameData.length && base64Data === lastFrameData) return;
82
+ lastFrameData = base64Data;
83
+ writeFileSync(tmpFile, Buffer.from(base64Data, 'base64'));
84
+ process.stdout.write(`${CURSOR_HOME}\x1b_Ga=T,f=100,t=f,q=2,C=1,i=1;${tmpPathB64}\x1b\\`);
85
+ }
86
+
87
+ // Inline mode (4096B chunked, PNG only)
88
+ // Prepends cursor-home and batches all chunks into a single stdout.write
89
+ function sendFrameInline(pngBase64) {
90
+ if (pngBase64.length === lastFrameData.length && pngBase64 === lastFrameData) return;
91
+ lastFrameData = pngBase64;
92
+ const CHUNK = 4096;
93
+ if (pngBase64.length <= CHUNK) {
94
+ process.stdout.write(`${CURSOR_HOME}\x1b_Ga=T,f=100,q=2,C=1,i=1;${pngBase64}\x1b\\`);
95
+ return;
96
+ }
97
+ const parts = [CURSOR_HOME];
98
+ let i = 0;
99
+ while (i < pngBase64.length) {
100
+ const chunk = pngBase64.slice(i, i + CHUNK);
101
+ const more = i + CHUNK < pngBase64.length ? 1 : 0;
102
+ if (i === 0) {
103
+ parts.push(`\x1b_Ga=T,f=100,q=2,C=1,i=1,m=${more};${chunk}\x1b\\`);
104
+ } else {
105
+ parts.push(`\x1b_Gm=${more};${chunk}\x1b\\`);
106
+ }
107
+ i += CHUNK;
108
+ }
109
+ process.stdout.write(parts.join(''));
110
+ }
111
+
112
+ // Reset dedup state (e.g. after resize)
113
+ export function resetFrameCache() {
114
+ lastFrameData = '';
115
+ }
116
+
117
+ export const sendFrame = transport === 'file' ? sendFrameFile : sendFrameInline;
package/lib/urlbar.js ADDED
@@ -0,0 +1,348 @@
1
+ // Address bar / search bar (always visible)
2
+ // Line 1 of the terminal always shows the current URL
3
+ // Alt+L to enter edit mode → Enter to navigate, Escape to cancel
4
+
5
+ import { showCursor, hideCursor } from './kitty.js';
6
+ import { loadConfig } from './config.js';
7
+ import { searchBookmarks } from './bookmarks.js';
8
+
9
+ function isURL(str) {
10
+ return /^https?:\/\//.test(str) || /^[a-zA-Z0-9-]+(\.[a-zA-Z]{2,})(\/|$)/.test(str);
11
+ }
12
+
13
+ function toURL(input) {
14
+ if (/^https?:\/\//.test(input)) return input;
15
+ if (isURL(input)) return 'https://' + input;
16
+
17
+ // /b [query] → bookmark search
18
+ const bm = input.match(/^\/b(?:\s+(.+))?$/);
19
+ if (bm) {
20
+ const results = searchBookmarks(bm[1] || '');
21
+ if (results.length > 0) return results[0].url;
22
+ return null; // No match
23
+ }
24
+
25
+ const config = loadConfig();
26
+ return config.searchUrl + encodeURIComponent(input);
27
+ }
28
+
29
+ // East Asian Width (UAX #11) full-width detection
30
+ // Character display width (full-width=2, half-width=1)
31
+ function charWidth(cp) {
32
+ if (
33
+ (cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
34
+ (cp >= 0x2E80 && cp <= 0x303E) || // CJK Radicals Supplement, Symbols
35
+ (cp >= 0x3040 && cp <= 0x33BF) || // Hiragana, Katakana, CJK Compatibility
36
+ (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Unified Ideographs Extension A
37
+ (cp >= 0x4E00 && cp <= 0xA4CF) || // CJK Unified Ideographs, Yi Syllables
38
+ (cp >= 0xAC00 && cp <= 0xD7FF) || // Hangul Syllables
39
+ (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
40
+ (cp >= 0xFE30 && cp <= 0xFE6F) || // CJK Compatibility Forms, Small Forms
41
+ (cp >= 0xFF01 && cp <= 0xFF60) || // Fullwidth Latin, Symbols
42
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Currency, Symbols
43
+ (cp >= 0x20000 && cp <= 0x2FA1F) // CJK Extensions B-F, Compatibility Supplement
44
+ ) return 2;
45
+ return 1;
46
+ }
47
+
48
+ function strWidth(str) {
49
+ let w = 0;
50
+ for (const ch of str) w += charWidth(ch.codePointAt(0));
51
+ return w;
52
+ }
53
+
54
+ // Truncate by display width (keep rightmost maxW columns)
55
+ function sliceByWidth(str, maxW) {
56
+ const chars = [...str];
57
+ let w = 0;
58
+ let start = chars.length;
59
+ for (let i = chars.length - 1; i >= 0; i--) {
60
+ const cw = charWidth(chars[i].codePointAt(0));
61
+ if (w + cw > maxW) break;
62
+ w += cw;
63
+ start = i;
64
+ }
65
+ return { text: chars.slice(start).join(''), width: w };
66
+ }
67
+
68
+ // padEnd by display width
69
+ function padEndByWidth(str, totalW) {
70
+ const w = strWidth(str);
71
+ return w >= totalW ? str : str + ' '.repeat(totalW - w);
72
+ }
73
+
74
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
75
+
76
+ export class UrlBar {
77
+ constructor() {
78
+ this.currentUrl = '';
79
+ this.editing = false;
80
+ this.loading = false;
81
+ this._spinIdx = 0;
82
+ this._status = null;
83
+ this.text = '';
84
+ this.cursor = 0;
85
+ this.selectAll = false; // Select-all state
86
+ this._resolve = null;
87
+ this._dirty = true; // Needs re-render
88
+ this._spinTimer = null; // Spinner update interval
89
+ }
90
+
91
+ // Update current URL and re-render (if not editing)
92
+ setUrl(url) {
93
+ this.currentUrl = url;
94
+ this._dirty = true;
95
+ if (!this.editing) this.render();
96
+ }
97
+
98
+ // Status message (downloads, etc.)
99
+ setStatus(msg) { this._status = msg; this._dirty = true; }
100
+ clearStatus() { this._status = null; this._dirty = true; }
101
+
102
+ // Render only if content changed (for frame callback)
103
+ renderIfDirty() {
104
+ if (!this._dirty) return;
105
+ this.render();
106
+ }
107
+
108
+ // Render on line 1
109
+ render() {
110
+ this._dirty = false;
111
+ this._updateSpinTimer();
112
+ const cols = process.stdout.columns || 80;
113
+ const line = this.editing ? this._editLine(cols) : this._displayLine(cols);
114
+ process.stdout.write(`\x1b[1;1H${line}\x1b[0m`);
115
+ if (this.editing) {
116
+ const prefix = ' > ';
117
+ const textBeforeCursor = [...this.text].slice(0, this.cursor).join('');
118
+ const cursorCol = prefix.length + strWidth(textBeforeCursor) + 1;
119
+ process.stdout.write(`\x1b[1;${cursorCol}H`);
120
+ }
121
+ }
122
+
123
+ // Start/stop spinner timer (100ms interval instead of every frame)
124
+ _updateSpinTimer() {
125
+ if (this.loading && !this._spinTimer) {
126
+ this._spinTimer = setInterval(() => { this._dirty = true; }, 100);
127
+ } else if (!this.loading && this._spinTimer) {
128
+ clearInterval(this._spinTimer);
129
+ this._spinTimer = null;
130
+ }
131
+ }
132
+
133
+ _displayLine(cols) {
134
+ let prefix;
135
+ if (this.loading) {
136
+ prefix = ' ' + SPINNER[this._spinIdx++ % SPINNER.length] + ' ';
137
+ } else {
138
+ prefix = ' ';
139
+ }
140
+ const content = this._status || this.currentUrl;
141
+ const full = prefix + content;
142
+ return `\x1b[38;5;250m\x1b[48;5;236m${padEndByWidth(full, cols)}`;
143
+ }
144
+
145
+ _editLine(cols) {
146
+ const prefix = ' > ';
147
+ const maxW = cols - prefix.length;
148
+ const tw = strWidth(this.text);
149
+ const display = tw > maxW ? sliceByWidth(this.text, maxW).text : this.text;
150
+ const dw = strWidth(display);
151
+ const pad = Math.max(0, maxW - dw);
152
+ if (this.selectAll) {
153
+ return `\x1b[48;5;24m\x1b[97m${prefix}\x1b[7m${display}\x1b[27m${' '.repeat(pad)}`;
154
+ }
155
+ return `\x1b[97m\x1b[48;5;24m${prefix}${display}${' '.repeat(pad)}`;
156
+ }
157
+
158
+ // Start editing mode (returns URL via Promise)
159
+ startEditing() {
160
+ this.editing = true;
161
+ this.selectAll = true;
162
+ this.text = this.currentUrl;
163
+ this.cursor = [...this.text].length;
164
+ showCursor();
165
+ this.render();
166
+ const { promise, resolve } = Promise.withResolvers();
167
+ this._resolve = resolve;
168
+ return promise;
169
+ }
170
+
171
+ // End editing mode
172
+ _finishEditing(result) {
173
+ this.editing = false;
174
+ hideCursor();
175
+
176
+ let url = null;
177
+ if (result) {
178
+ url = toURL(result);
179
+ if (url === null) {
180
+ // Bookmark not found
181
+ this.setStatus('Bookmark not found');
182
+ setTimeout(() => this.clearStatus(), 2000);
183
+ }
184
+ }
185
+
186
+ this.render();
187
+ if (this._resolve) {
188
+ this._resolve(url);
189
+ this._resolve = null;
190
+ }
191
+ }
192
+
193
+ // Clear selection
194
+ _deselect() { this.selectAll = false; }
195
+
196
+ // If selected, replace all on input/delete
197
+ _clearIfSelected() {
198
+ if (this.selectAll) {
199
+ this.text = '';
200
+ this.cursor = 0;
201
+ this.selectAll = false;
202
+ }
203
+ }
204
+
205
+ // Insert text (for paste)
206
+ insertText(str) {
207
+ if (!this.editing) return;
208
+ this._clearIfSelected();
209
+ const chars = [...this.text];
210
+ const input = [...str];
211
+ this.text = chars.slice(0, this.cursor).join('') + str + chars.slice(this.cursor).join('');
212
+ this.cursor += input.length;
213
+ this.render();
214
+ }
215
+
216
+ // Handle key input during editing (returns true if consumed)
217
+ handleInput(str) {
218
+ if (!this.editing) return false;
219
+
220
+ // Enter → confirm
221
+ if (str === '\r' || str === '\n') {
222
+ this._finishEditing(this.text.trim() || null);
223
+ return true;
224
+ }
225
+
226
+ // Escape → cancel
227
+ if (str === '\x1b') {
228
+ this._finishEditing(null);
229
+ return true;
230
+ }
231
+
232
+ // Ctrl+C → cancel
233
+ if (str === '\x03') {
234
+ this._finishEditing(null);
235
+ return true;
236
+ }
237
+
238
+ // Ctrl+U → clear all
239
+ if (str === '\x15') {
240
+ this.text = '';
241
+ this.cursor = 0;
242
+ this._deselect();
243
+ this.render();
244
+ return true;
245
+ }
246
+
247
+ // Ctrl+A → select all
248
+ if (str === '\x01') {
249
+ this.selectAll = true;
250
+ this.cursor = [...this.text].length;
251
+ this.render();
252
+ return true;
253
+ }
254
+
255
+ // Ctrl+E → move to end
256
+ if (str === '\x05') {
257
+ this._deselect();
258
+ this.cursor = [...this.text].length;
259
+ this.render();
260
+ return true;
261
+ }
262
+
263
+ // Ctrl+W → delete word
264
+ if (str === '\x17') {
265
+ this._clearIfSelected();
266
+ const chars = [...this.text];
267
+ const before = chars.slice(0, this.cursor).join('');
268
+ const after = chars.slice(this.cursor).join('');
269
+ const trimmed = before.replace(/\S+\s*$/, '');
270
+ this.text = trimmed + after;
271
+ this.cursor = [...trimmed].length;
272
+ this.render();
273
+ return true;
274
+ }
275
+
276
+ // Backspace
277
+ if (str === '\x7f' || str === '\x08') {
278
+ if (this.selectAll) {
279
+ this._clearIfSelected();
280
+ } else if (this.cursor > 0) {
281
+ const chars = [...this.text];
282
+ chars.splice(this.cursor - 1, 1);
283
+ this.text = chars.join('');
284
+ this.cursor--;
285
+ }
286
+ this.render();
287
+ return true;
288
+ }
289
+
290
+ // Delete
291
+ if (str === '\x1b[3~') {
292
+ if (this.selectAll) {
293
+ this._clearIfSelected();
294
+ } else if (this.cursor < [...this.text].length) {
295
+ const chars = [...this.text];
296
+ chars.splice(this.cursor, 1);
297
+ this.text = chars.join('');
298
+ }
299
+ this.render();
300
+ return true;
301
+ }
302
+
303
+ // Left arrow → deselect and move to start
304
+ if (str === '\x1b[D') {
305
+ if (this.selectAll) { this.cursor = 0; this._deselect(); }
306
+ else if (this.cursor > 0) this.cursor--;
307
+ this.render();
308
+ return true;
309
+ }
310
+
311
+ // Right arrow → deselect and move to end
312
+ if (str === '\x1b[C') {
313
+ if (this.selectAll) { this._deselect(); }
314
+ else if (this.cursor < [...this.text].length) this.cursor++;
315
+ this.render();
316
+ return true;
317
+ }
318
+
319
+ // Home
320
+ if (str === '\x1b[H') {
321
+ this._deselect();
322
+ this.cursor = 0;
323
+ this.render();
324
+ return true;
325
+ }
326
+
327
+ // End
328
+ if (str === '\x1b[F') {
329
+ this._deselect();
330
+ this.cursor = [...this.text].length;
331
+ this.render();
332
+ return true;
333
+ }
334
+
335
+ // Normal character input → replace all if selected
336
+ if (!str.startsWith('\x1b') && str.charCodeAt(0) >= 32) {
337
+ this._clearIfSelected();
338
+ const chars = [...this.text];
339
+ const input = [...str];
340
+ this.text = chars.slice(0, this.cursor).join('') + str + chars.slice(this.cursor).join('');
341
+ this.cursor += input.length;
342
+ this.render();
343
+ return true;
344
+ }
345
+
346
+ return true; // Consume all input while editing
347
+ }
348
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@sanohiro/casty",
3
+ "version": "0.5.0",
4
+ "description": "TTY web browser using raw CDP and Kitty graphics protocol",
5
+ "main": "bin/casty.js",
6
+ "bin": {
7
+ "casty": "bin/casty"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "start": "./bin/casty",
16
+ "postinstall": "echo 'Run casty to auto-install Chrome Headless Shell to ~/.casty/browsers/'"
17
+ },
18
+ "keywords": [
19
+ "browser",
20
+ "tty",
21
+ "terminal",
22
+ "kitty",
23
+ "cdp",
24
+ "headless",
25
+ "chrome"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/sanohiro/casty.git"
30
+ },
31
+ "homepage": "https://github.com/sanohiro/casty",
32
+ "author": "Hironobu Sano",
33
+ "license": "MIT",
34
+ "type": "module",
35
+ "engines": {
36
+ "node": ">=22.0.0"
37
+ },
38
+ "dependencies": {
39
+ "ws": "^8.0.0"
40
+ }
41
+ }