hop2 0.8.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/.hop-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { WebSocket } from "ws";
|
|
8
|
+
import { safeParseServerMessage, type ClientMessage, type PresenceClient } from "hay-shared";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const isMac = process.platform === "darwin";
|
|
12
|
+
const keyLabelShort = isMac ? "Opt" : "Alt";
|
|
13
|
+
const keyLabelLong = isMac ? "Option" : "Alt";
|
|
14
|
+
|
|
15
|
+
type CliConfig = { showHints?: boolean; scrollOff?: number };
|
|
16
|
+
type HopConfig = { "hay-cli"?: CliConfig };
|
|
17
|
+
const DEFAULT_SCROLL_OFF = 3;
|
|
18
|
+
|
|
19
|
+
// Config file locations (checked in order, first found wins)
|
|
20
|
+
const CONFIG_LOCATIONS = [
|
|
21
|
+
// Local configs (current directory)
|
|
22
|
+
{ path: ".hay-cli.json", isLegacy: true, isLocal: true },
|
|
23
|
+
{ path: ".hop.json", isLegacy: false, isLocal: true },
|
|
24
|
+
// Global configs (home directory)
|
|
25
|
+
{ path: path.join(os.homedir(), ".hay-cli.json"), isLegacy: true, isLocal: false },
|
|
26
|
+
{ path: path.join(os.homedir(), ".hop.json"), isLegacy: false, isLocal: false },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Find which config file to use
|
|
30
|
+
const getConfigPath = (): { path: string; isLegacy: boolean; isLocal: boolean } | null => {
|
|
31
|
+
for (const loc of CONFIG_LOCATIONS) {
|
|
32
|
+
if (fs.existsSync(loc.path)) {
|
|
33
|
+
return loc;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Get the path where we should save config (prefer global .hop.json)
|
|
40
|
+
const getSaveConfigPath = (): { path: string; isLegacy: boolean } => {
|
|
41
|
+
// If a legacy file exists anywhere, use the global legacy path
|
|
42
|
+
const existing = getConfigPath();
|
|
43
|
+
if (existing?.isLegacy) {
|
|
44
|
+
return { path: path.join(os.homedir(), ".hay-cli.json"), isLegacy: true };
|
|
45
|
+
}
|
|
46
|
+
// Otherwise use global .hop.json
|
|
47
|
+
return { path: path.join(os.homedir(), ".hop.json"), isLegacy: false };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const loadConfig = (): CliConfig => {
|
|
51
|
+
try {
|
|
52
|
+
const loc = getConfigPath();
|
|
53
|
+
if (!loc) return {};
|
|
54
|
+
const raw = fs.readFileSync(loc.path, "utf8");
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
if (loc.isLegacy) {
|
|
57
|
+
return (parsed as CliConfig) ?? {};
|
|
58
|
+
}
|
|
59
|
+
// Read from hay-cli key in .hop.json
|
|
60
|
+
return (parsed as HopConfig)?.["hay-cli"] ?? {};
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const saveConfig = (config: CliConfig) => {
|
|
67
|
+
try {
|
|
68
|
+
const { path: configPath, isLegacy } = getSaveConfigPath();
|
|
69
|
+
if (isLegacy) {
|
|
70
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
71
|
+
} else {
|
|
72
|
+
// Read existing .hop.json and update hay-cli key
|
|
73
|
+
let hopConfig: HopConfig = {};
|
|
74
|
+
if (fs.existsSync(configPath)) {
|
|
75
|
+
try {
|
|
76
|
+
hopConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
hopConfig = {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
hopConfig["hay-cli"] = config;
|
|
82
|
+
fs.writeFileSync(configPath, JSON.stringify(hopConfig, null, 2));
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Ignore config write errors
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const globalWindow = globalThis as typeof globalThis & {
|
|
90
|
+
window?: typeof globalThis;
|
|
91
|
+
requestIdleCallback?: (callback: (deadline: { timeRemaining: () => number; didTimeout: boolean }) => void) => NodeJS.Timeout;
|
|
92
|
+
cancelIdleCallback?: (handle: NodeJS.Timeout) => void;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (!globalWindow.window) {
|
|
96
|
+
globalWindow.window = globalWindow;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!globalWindow.requestIdleCallback) {
|
|
100
|
+
globalWindow.requestIdleCallback = (callback) => {
|
|
101
|
+
return setTimeout(() => callback({ timeRemaining: () => 0, didTimeout: false }), 0);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!globalWindow.cancelIdleCallback) {
|
|
106
|
+
globalWindow.cancelIdleCallback = (handle) => {
|
|
107
|
+
clearTimeout(handle);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const require = createRequire(import.meta.url);
|
|
112
|
+
const { Terminal } = require("xterm-headless") as typeof import("xterm-headless");
|
|
113
|
+
|
|
114
|
+
function parseArgs() {
|
|
115
|
+
let server = "ws://localhost:4001/ws";
|
|
116
|
+
let room = "";
|
|
117
|
+
let name = process.env.USER || "cli-user";
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < args.length; i++) {
|
|
120
|
+
const arg = args[i];
|
|
121
|
+
if (arg === "--server" || arg === "-s") {
|
|
122
|
+
server = args[++i] || server;
|
|
123
|
+
} else if (arg === "--room" || arg === "-r") {
|
|
124
|
+
room = args[++i] || room;
|
|
125
|
+
} else if (arg === "--name" || arg === "-n") {
|
|
126
|
+
name = args[++i] || name;
|
|
127
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
128
|
+
console.log(`
|
|
129
|
+
hay-cli - Connect to a hay room from your terminal
|
|
130
|
+
|
|
131
|
+
Usage:
|
|
132
|
+
hay [options]
|
|
133
|
+
|
|
134
|
+
Options:
|
|
135
|
+
-s, --server <url> WebSocket server URL (default: ws://localhost:4001/ws)
|
|
136
|
+
-r, --room <name> Room name (required)
|
|
137
|
+
-n, --name <name> Display name (default: $USER)
|
|
138
|
+
-h, --help Show this help
|
|
139
|
+
|
|
140
|
+
Keyboard shortcuts:
|
|
141
|
+
Ctrl+G Detach (session continues in background)
|
|
142
|
+
Ctrl+Q Kill session and exit
|
|
143
|
+
Ctrl+T Toggle hint bar (saved)
|
|
144
|
+
${keyLabelLong}+←/→/↑/↓ Pan viewport (Shift = faster)
|
|
145
|
+
${keyLabelLong}+A Toggle autofit (resize remote to local)
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
hay -r my-room
|
|
149
|
+
hay -r my-room -n alice -s ws://example.com/ws
|
|
150
|
+
`);
|
|
151
|
+
process.exit(0);
|
|
152
|
+
} else if (!room) {
|
|
153
|
+
room = arg;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { server, room, name };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const config = parseArgs();
|
|
161
|
+
|
|
162
|
+
if (!config.room) {
|
|
163
|
+
console.error("Error: Room name is required. Use -r <room> or pass room name as argument.");
|
|
164
|
+
console.error("Run with --help for usage information.");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const DETACH_EXIT_CODE = 10;
|
|
169
|
+
const KILL_EXIT_CODE = 11;
|
|
170
|
+
const TYPING_IDLE_MS = 1200;
|
|
171
|
+
const NOTICE_MS = 3500;
|
|
172
|
+
const PAN_STEP = 2;
|
|
173
|
+
const PAN_FAST_STEP = 10;
|
|
174
|
+
let cursorVisible = true;
|
|
175
|
+
const SCROLL_STEP = 3;
|
|
176
|
+
let pendingInput = "";
|
|
177
|
+
type RgbColor = { r: number; g: number; b: number };
|
|
178
|
+
let defaultFg: RgbColor | null = null;
|
|
179
|
+
let defaultBg: RgbColor | null = null;
|
|
180
|
+
let defaultCursor: RgbColor | null = null;
|
|
181
|
+
let pendingOsc = "";
|
|
182
|
+
|
|
183
|
+
let ws: WebSocket | null = null;
|
|
184
|
+
let clientId: string | null = null;
|
|
185
|
+
let connected = false;
|
|
186
|
+
let status: "connecting" | "connected" | "reconnecting" | "disconnected" = "connecting";
|
|
187
|
+
let shouldReconnect = true;
|
|
188
|
+
let reconnectAttempt = 0;
|
|
189
|
+
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
190
|
+
let typingTimeout: NodeJS.Timeout | null = null;
|
|
191
|
+
let noticeTimer: NodeJS.Timeout | null = null;
|
|
192
|
+
let exitCode = 0;
|
|
193
|
+
let exitMessage: string | null = null;
|
|
194
|
+
|
|
195
|
+
let collabMode = true;
|
|
196
|
+
let controllerId: string | null = null;
|
|
197
|
+
let presence: PresenceClient[] = [];
|
|
198
|
+
let notice: { message: string; expiresAt: number } | null = null;
|
|
199
|
+
|
|
200
|
+
let syncSize = true;
|
|
201
|
+
let mouseCapture = true;
|
|
202
|
+
let viewX = 0;
|
|
203
|
+
let viewY = 0;
|
|
204
|
+
let showHints = true;
|
|
205
|
+
let scrollOff = DEFAULT_SCROLL_OFF;
|
|
206
|
+
|
|
207
|
+
const configFile = loadConfig();
|
|
208
|
+
showHints = configFile.showHints ?? true;
|
|
209
|
+
scrollOff = configFile.scrollOff ?? DEFAULT_SCROLL_OFF;
|
|
210
|
+
|
|
211
|
+
let uiInitialized = false;
|
|
212
|
+
let renderScheduled = false;
|
|
213
|
+
let lastRenderCols = 0;
|
|
214
|
+
let lastRenderRows = 0;
|
|
215
|
+
|
|
216
|
+
const getLocalMetrics = () => {
|
|
217
|
+
const cols = process.stdout.columns || 80;
|
|
218
|
+
const rows = process.stdout.rows || 24;
|
|
219
|
+
const showBottomBar = rows >= 2;
|
|
220
|
+
const showHintBar = showHints && rows >= 3;
|
|
221
|
+
const barRows = (showBottomBar ? 1 : 0) + (showHintBar ? 1 : 0);
|
|
222
|
+
return {
|
|
223
|
+
cols,
|
|
224
|
+
rows,
|
|
225
|
+
barRows,
|
|
226
|
+
showBottomBar,
|
|
227
|
+
showHintBar,
|
|
228
|
+
viewportCols: Math.max(1, cols),
|
|
229
|
+
viewportRows: Math.max(1, rows - barRows)
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
let localMetrics = getLocalMetrics();
|
|
234
|
+
|
|
235
|
+
let remoteCols = Math.max(2, localMetrics.viewportCols);
|
|
236
|
+
let remoteRows = Math.max(1, localMetrics.viewportRows);
|
|
237
|
+
|
|
238
|
+
const terminal = new Terminal({
|
|
239
|
+
cols: remoteCols,
|
|
240
|
+
rows: remoteRows,
|
|
241
|
+
scrollback: 2000,
|
|
242
|
+
allowProposedApi: true
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Forward terminal-generated responses (eg. DSR cursor reports) back to the PTY.
|
|
246
|
+
terminal.onData((data) => {
|
|
247
|
+
if (!data) return;
|
|
248
|
+
sendMessage({ type: "input", data });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const filterFocusSequences = (data: string) => data.replace(/\x1b\[I/g, "").replace(/\x1b\[O/g, "");
|
|
252
|
+
|
|
253
|
+
const stripMouseSequences = (data: string) => {
|
|
254
|
+
let didScroll = false;
|
|
255
|
+
const cleaned = data.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, buttonText, _x, _y, kind) => {
|
|
256
|
+
const button = Number(buttonText);
|
|
257
|
+
const isWheel = (button & 64) === 64;
|
|
258
|
+
if (isWheel && kind === "M") {
|
|
259
|
+
const direction = (button & 1) === 1 ? 1 : -1;
|
|
260
|
+
viewY += direction * SCROLL_STEP;
|
|
261
|
+
clampView();
|
|
262
|
+
scheduleRender();
|
|
263
|
+
didScroll = true;
|
|
264
|
+
}
|
|
265
|
+
return "";
|
|
266
|
+
});
|
|
267
|
+
return { cleaned, didScroll };
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const parseOscColor = (value: string): RgbColor | null => {
|
|
271
|
+
const trimmed = value.trim().toLowerCase();
|
|
272
|
+
if (!trimmed || trimmed === "default") {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
if (trimmed.startsWith("#")) {
|
|
276
|
+
const hex = trimmed.slice(1);
|
|
277
|
+
if (hex.length === 6) {
|
|
278
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
279
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
280
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
281
|
+
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
|
282
|
+
return { r, g, b };
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (trimmed.startsWith("rgb:")) {
|
|
287
|
+
const parts = trimmed.slice(4).split("/");
|
|
288
|
+
if (parts.length < 3) return null;
|
|
289
|
+
const parsePart = (part: string) => {
|
|
290
|
+
if (!part) return null;
|
|
291
|
+
const valueNum = parseInt(part, 16);
|
|
292
|
+
if (Number.isNaN(valueNum)) return null;
|
|
293
|
+
const max = Math.pow(16, part.length) - 1;
|
|
294
|
+
return Math.round((valueNum / max) * 255);
|
|
295
|
+
};
|
|
296
|
+
const r = parsePart(parts[0]);
|
|
297
|
+
const g = parsePart(parts[1]);
|
|
298
|
+
const b = parsePart(parts[2]);
|
|
299
|
+
if (r === null || g === null || b === null) return null;
|
|
300
|
+
return { r, g, b };
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const trackOscColors = (chunk: string) => {
|
|
306
|
+
const data = pendingOsc + chunk;
|
|
307
|
+
pendingOsc = "";
|
|
308
|
+
const oscPattern = /\x1b\](10|11|12);([\s\S]*?)(\x07|\x1b\\)/g;
|
|
309
|
+
let match: RegExpExecArray | null;
|
|
310
|
+
let lastMatchEnd = 0;
|
|
311
|
+
while ((match = oscPattern.exec(data))) {
|
|
312
|
+
lastMatchEnd = oscPattern.lastIndex;
|
|
313
|
+
const id = Number(match[1]);
|
|
314
|
+
const payload = match[2].trim();
|
|
315
|
+
if (payload === "?") {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const color = parseOscColor(payload);
|
|
319
|
+
if (id === 10) {
|
|
320
|
+
defaultFg = color;
|
|
321
|
+
} else if (id === 11) {
|
|
322
|
+
defaultBg = color;
|
|
323
|
+
} else if (id === 12) {
|
|
324
|
+
defaultCursor = color;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const tailIndex = data.lastIndexOf("\x1b]");
|
|
328
|
+
if (tailIndex !== -1 && tailIndex >= lastMatchEnd) {
|
|
329
|
+
const tail = data.slice(tailIndex);
|
|
330
|
+
if (!tail.includes("\x07") && !tail.includes("\x1b\\")) {
|
|
331
|
+
pendingOsc = tail;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (lastMatchEnd > 0) {
|
|
335
|
+
scheduleRender();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
340
|
+
|
|
341
|
+
const getMaxViewY = () => {
|
|
342
|
+
const buffer = terminal.buffer.active;
|
|
343
|
+
const max = buffer.length - localMetrics.viewportRows;
|
|
344
|
+
return Math.max(0, max);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const clampView = () => {
|
|
348
|
+
const maxX = Math.max(0, remoteCols - localMetrics.viewportCols);
|
|
349
|
+
const maxY = getMaxViewY();
|
|
350
|
+
viewX = clamp(viewX, 0, maxX);
|
|
351
|
+
viewY = clamp(viewY, 0, maxY);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const ensureCursorVisible = (_force: boolean) => {
|
|
355
|
+
const buffer = terminal.buffer.active;
|
|
356
|
+
const cursorX = buffer.cursorX;
|
|
357
|
+
const cursorRow = buffer.baseY + buffer.cursorY;
|
|
358
|
+
let nextX = viewX;
|
|
359
|
+
let nextY = viewY;
|
|
360
|
+
const maxY = getMaxViewY();
|
|
361
|
+
|
|
362
|
+
if (cursorX < viewX) {
|
|
363
|
+
nextX = cursorX;
|
|
364
|
+
} else if (cursorX >= viewX + localMetrics.viewportCols) {
|
|
365
|
+
nextX = cursorX - localMetrics.viewportCols + 1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (cursorRow < viewY) {
|
|
369
|
+
nextY = cursorRow;
|
|
370
|
+
} else if (cursorRow >= viewY + localMetrics.viewportRows) {
|
|
371
|
+
nextY = cursorRow - localMetrics.viewportRows + 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
viewX = nextX;
|
|
375
|
+
viewY = clamp(nextY, 0, maxY);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const scheduleRender = () => {
|
|
379
|
+
if (renderScheduled) return;
|
|
380
|
+
renderScheduled = true;
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
renderScheduled = false;
|
|
383
|
+
render();
|
|
384
|
+
}, 0);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const setCursorVisible = (visible: boolean) => {
|
|
388
|
+
cursorVisible = visible;
|
|
389
|
+
scheduleRender();
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
|
|
393
|
+
if (params.includes(25)) {
|
|
394
|
+
setCursorVisible(false);
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
|
|
400
|
+
if (params.includes(25)) {
|
|
401
|
+
setCursorVisible(true);
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const pushNotice = (message: string) => {
|
|
407
|
+
notice = { message, expiresAt: Date.now() + NOTICE_MS };
|
|
408
|
+
if (noticeTimer) {
|
|
409
|
+
clearTimeout(noticeTimer);
|
|
410
|
+
}
|
|
411
|
+
noticeTimer = setTimeout(() => {
|
|
412
|
+
notice = null;
|
|
413
|
+
scheduleRender();
|
|
414
|
+
}, NOTICE_MS);
|
|
415
|
+
scheduleRender();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const sendMessage = (message: ClientMessage) => {
|
|
419
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
420
|
+
ws.send(JSON.stringify(message));
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const sendOscResponse = (id: number, color: RgbColor | null) => {
|
|
425
|
+
if (!color) return;
|
|
426
|
+
const toHex4 = (value: number) => Math.round(value * 257).toString(16).padStart(4, "0");
|
|
427
|
+
const payload = `\x1b]${id};rgb:${toHex4(color.r)}/${toHex4(color.g)}/${toHex4(color.b)}\x1b\\`;
|
|
428
|
+
sendMessage({ type: "input", data: payload });
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
terminal.parser.registerOscHandler(10, (data) => {
|
|
432
|
+
if (data.trim() === "?") {
|
|
433
|
+
sendOscResponse(10, defaultFg);
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
defaultFg = parseOscColor(data);
|
|
437
|
+
scheduleRender();
|
|
438
|
+
return true;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
terminal.parser.registerOscHandler(11, (data) => {
|
|
442
|
+
if (data.trim() === "?") {
|
|
443
|
+
sendOscResponse(11, defaultBg);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
defaultBg = parseOscColor(data);
|
|
447
|
+
scheduleRender();
|
|
448
|
+
return true;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
terminal.parser.registerOscHandler(12, (data) => {
|
|
452
|
+
if (data.trim() === "?") {
|
|
453
|
+
sendOscResponse(12, defaultCursor);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
defaultCursor = parseOscColor(data);
|
|
457
|
+
return true;
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const sendTyping = (active: boolean) => {
|
|
461
|
+
sendMessage({ type: "typing", active });
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const handleTyping = () => {
|
|
465
|
+
sendTyping(true);
|
|
466
|
+
if (typingTimeout) {
|
|
467
|
+
clearTimeout(typingTimeout);
|
|
468
|
+
}
|
|
469
|
+
typingTimeout = setTimeout(() => {
|
|
470
|
+
sendTyping(false);
|
|
471
|
+
}, TYPING_IDLE_MS);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const getUrl = () => {
|
|
475
|
+
const separator = config.server.includes("?") ? "&" : "?";
|
|
476
|
+
return `${config.server}${separator}room=${encodeURIComponent(config.room)}&name=${encodeURIComponent(
|
|
477
|
+
config.name
|
|
478
|
+
)}&cols=${localMetrics.viewportCols}&rows=${localMetrics.viewportRows}`;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const buildShareUrl = () => {
|
|
482
|
+
const explicit = process.env.HAY_SHARE_URL || process.env.HOP_PUBLIC_URL || "";
|
|
483
|
+
|
|
484
|
+
const normalize = (raw: string) => {
|
|
485
|
+
try {
|
|
486
|
+
const url = new URL(raw);
|
|
487
|
+
return url.origin;
|
|
488
|
+
} catch {
|
|
489
|
+
return raw;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
if (explicit) {
|
|
494
|
+
return normalize(explicit);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const wsUrl = new URL(config.server);
|
|
499
|
+
const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";
|
|
500
|
+
return normalize(`${protocol}//${wsUrl.host}`);
|
|
501
|
+
} catch {
|
|
502
|
+
return "";
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const setStatus = (nextStatus: typeof status) => {
|
|
507
|
+
status = nextStatus;
|
|
508
|
+
scheduleRender();
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const applyRemoteSize = (cols: number, rows: number) => {
|
|
512
|
+
localMetrics = getLocalMetrics();
|
|
513
|
+
const nextCols = Math.max(2, Math.min(cols, 500));
|
|
514
|
+
const nextRows = Math.max(1, Math.min(rows, 200));
|
|
515
|
+
if (remoteCols === nextCols && remoteRows === nextRows) return;
|
|
516
|
+
remoteCols = nextCols;
|
|
517
|
+
remoteRows = nextRows;
|
|
518
|
+
terminal.resize(remoteCols, remoteRows);
|
|
519
|
+
// Only scroll if cursor is outside viewport (with scrollOff margin)
|
|
520
|
+
const buffer = terminal.buffer.active;
|
|
521
|
+
const cursorRow = buffer.baseY + buffer.cursorY;
|
|
522
|
+
const margin = Math.min(scrollOff, Math.floor(localMetrics.viewportRows / 2));
|
|
523
|
+
if (cursorRow < viewY + margin) {
|
|
524
|
+
viewY = Math.max(0, cursorRow - margin);
|
|
525
|
+
} else if (cursorRow >= viewY + localMetrics.viewportRows - margin) {
|
|
526
|
+
viewY = cursorRow - localMetrics.viewportRows + margin + 1;
|
|
527
|
+
}
|
|
528
|
+
clampView();
|
|
529
|
+
scheduleRender();
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const applySyncSize = () => {
|
|
533
|
+
if (!syncSize) return;
|
|
534
|
+
localMetrics = getLocalMetrics();
|
|
535
|
+
const cols = localMetrics.viewportCols;
|
|
536
|
+
const rows = localMetrics.viewportRows;
|
|
537
|
+
const needsResize = cols !== remoteCols || rows !== remoteRows;
|
|
538
|
+
applyRemoteSize(cols, rows);
|
|
539
|
+
if (needsResize) {
|
|
540
|
+
sendMessage({ type: "resize", cols, rows });
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const enableMouseCapture = () => {
|
|
545
|
+
if (!process.stdout.isTTY) return;
|
|
546
|
+
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const disableMouseCapture = () => {
|
|
550
|
+
if (!process.stdout.isTTY) return;
|
|
551
|
+
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const setMouseCapture = (enabled: boolean) => {
|
|
555
|
+
mouseCapture = enabled;
|
|
556
|
+
if (uiInitialized) {
|
|
557
|
+
if (mouseCapture) {
|
|
558
|
+
enableMouseCapture();
|
|
559
|
+
} else {
|
|
560
|
+
disableMouseCapture();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
scheduleRender();
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const initUi = () => {
|
|
567
|
+
if (uiInitialized || !process.stdout.isTTY) return;
|
|
568
|
+
uiInitialized = true;
|
|
569
|
+
// Enable alternate screen (mouse capture is optional)
|
|
570
|
+
process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
|
|
571
|
+
if (mouseCapture) {
|
|
572
|
+
enableMouseCapture();
|
|
573
|
+
}
|
|
574
|
+
lastRenderCols = 0;
|
|
575
|
+
lastRenderRows = 0;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const restoreUi = () => {
|
|
579
|
+
if (!uiInitialized || !process.stdout.isTTY) return;
|
|
580
|
+
disableMouseCapture();
|
|
581
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l\x1b[0m");
|
|
582
|
+
uiInitialized = false;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
|
|
586
|
+
|
|
587
|
+
const visibleLength = (text: string) => text.replace(ANSI_REGEX, "").length;
|
|
588
|
+
|
|
589
|
+
const truncateAnsi = (text: string, cols: number) => {
|
|
590
|
+
if (cols <= 0) return "";
|
|
591
|
+
let out = "";
|
|
592
|
+
let visible = 0;
|
|
593
|
+
for (let i = 0; i < text.length && visible < cols; i += 1) {
|
|
594
|
+
const char = text[i];
|
|
595
|
+
if (char === "\x1b" && text[i + 1] === "[") {
|
|
596
|
+
const end = text.indexOf("m", i + 2);
|
|
597
|
+
if (end === -1) {
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
out += text.slice(i, end + 1);
|
|
601
|
+
i = end;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
out += char;
|
|
605
|
+
visible += 1;
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const padOrTrim = (text: string, cols: number) => {
|
|
611
|
+
const len = visibleLength(text);
|
|
612
|
+
if (len > cols) return truncateAnsi(text, cols);
|
|
613
|
+
if (len < cols) return text + " ".repeat(cols - len);
|
|
614
|
+
return text;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const composeBar = (left: string, right: string, cols: number) => {
|
|
618
|
+
if (!right) {
|
|
619
|
+
return padOrTrim(left, cols);
|
|
620
|
+
}
|
|
621
|
+
const rightLen = visibleLength(right);
|
|
622
|
+
if (rightLen >= cols) {
|
|
623
|
+
return padOrTrim(right.slice(0, cols), cols);
|
|
624
|
+
}
|
|
625
|
+
const availableLeft = Math.max(0, cols - rightLen - 1);
|
|
626
|
+
const trimmedLeft = visibleLength(left) > availableLeft ? truncateAnsi(left, availableLeft) : left;
|
|
627
|
+
const gap = Math.max(1, cols - visibleLength(trimmedLeft) - rightLen);
|
|
628
|
+
return padOrTrim(trimmedLeft + " ".repeat(gap) + right, cols);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const BAR_TOP_BG = "\x1b[48;5;254m";
|
|
632
|
+
const BAR_BOTTOM_BG = "\x1b[48;5;254m";
|
|
633
|
+
const BAR_FG = "\x1b[38;5;234m";
|
|
634
|
+
const BAR_ACCENT = "\x1b[38;5;93m";
|
|
635
|
+
const BAR_DIM = "\x1b[38;5;245m";
|
|
636
|
+
const BAR_BOLD = "\x1b[1m";
|
|
637
|
+
const BAR_RESET_FG = "\x1b[22m\x1b[38;5;234m";
|
|
638
|
+
|
|
639
|
+
const renderBar = (text: string, variant: "top" | "bottom") =>
|
|
640
|
+
`${variant === "top" ? BAR_TOP_BG : BAR_BOTTOM_BG}${BAR_FG}${text}\x1b[0m`;
|
|
641
|
+
|
|
642
|
+
const decorateTop = (text: string, secondaryTokens: string[], boldTokens: string[]) => {
|
|
643
|
+
let output = text;
|
|
644
|
+
output = output.replace("●", `${BAR_ACCENT}●${BAR_RESET_FG}`);
|
|
645
|
+
output = output.replace(/ \| /g, ` ${BAR_DIM}|${BAR_RESET_FG} `);
|
|
646
|
+
for (const token of boldTokens) {
|
|
647
|
+
if (!token) continue;
|
|
648
|
+
output = output.replace(token, `${BAR_BOLD}${token}${BAR_RESET_FG}`);
|
|
649
|
+
}
|
|
650
|
+
for (const token of secondaryTokens) {
|
|
651
|
+
if (!token) continue;
|
|
652
|
+
output = output.replace(token, `${BAR_DIM}${token}${BAR_RESET_FG}`);
|
|
653
|
+
}
|
|
654
|
+
return output;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const decorateBottom = (text: string, secondaryTokens: string[]) => {
|
|
658
|
+
let output = text;
|
|
659
|
+
output = output.replace(/ \| /g, ` ${BAR_DIM}|${BAR_RESET_FG} `);
|
|
660
|
+
for (const token of secondaryTokens) {
|
|
661
|
+
if (!token) continue;
|
|
662
|
+
output = output.replace(token, `${BAR_DIM}${token}${BAR_RESET_FG}`);
|
|
663
|
+
}
|
|
664
|
+
return output;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
type CellStyle = {
|
|
668
|
+
key: string;
|
|
669
|
+
ansi: string;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const styleKeyForCell = (
|
|
673
|
+
cell: ReturnType<typeof terminal.buffer.active.getNullCell>,
|
|
674
|
+
forceInverse: boolean
|
|
675
|
+
): CellStyle => {
|
|
676
|
+
const bold = !!cell.isBold();
|
|
677
|
+
const dim = !!cell.isDim();
|
|
678
|
+
const italic = !!cell.isItalic();
|
|
679
|
+
const underline = !!cell.isUnderline();
|
|
680
|
+
const blink = !!cell.isBlink();
|
|
681
|
+
const inverse = forceInverse || !!cell.isInverse();
|
|
682
|
+
const invisible = !!cell.isInvisible();
|
|
683
|
+
const strike = !!cell.isStrikethrough();
|
|
684
|
+
const overline = !!cell.isOverline();
|
|
685
|
+
|
|
686
|
+
let fgMode = cell.isFgRGB() ? "rgb" : cell.isFgPalette() ? "palette" : "default";
|
|
687
|
+
let bgMode = cell.isBgRGB() ? "rgb" : cell.isBgPalette() ? "palette" : "default";
|
|
688
|
+
let fg = cell.getFgColor();
|
|
689
|
+
let bg = cell.getBgColor();
|
|
690
|
+
const inverseUsesDefault = inverse && fgMode === "default" && bgMode === "default";
|
|
691
|
+
if (inverseUsesDefault && (defaultFg || defaultBg)) {
|
|
692
|
+
fgMode = defaultBg ? "rgb" : "default";
|
|
693
|
+
bgMode = defaultFg ? "rgb" : "default";
|
|
694
|
+
fg = defaultBg ? (defaultBg.r << 16) + (defaultBg.g << 8) + defaultBg.b : -1;
|
|
695
|
+
bg = defaultFg ? (defaultFg.r << 16) + (defaultFg.g << 8) + defaultFg.b : -1;
|
|
696
|
+
} else if (inverse && !inverseUsesDefault) {
|
|
697
|
+
[fgMode, bgMode] = [bgMode, fgMode];
|
|
698
|
+
[fg, bg] = [bg, fg];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const key = [
|
|
702
|
+
bold ? 1 : 0,
|
|
703
|
+
dim ? 1 : 0,
|
|
704
|
+
italic ? 1 : 0,
|
|
705
|
+
underline ? 1 : 0,
|
|
706
|
+
blink ? 1 : 0,
|
|
707
|
+
inverse ? 1 : 0,
|
|
708
|
+
invisible ? 1 : 0,
|
|
709
|
+
strike ? 1 : 0,
|
|
710
|
+
overline ? 1 : 0,
|
|
711
|
+
fgMode,
|
|
712
|
+
fg,
|
|
713
|
+
bgMode,
|
|
714
|
+
bg
|
|
715
|
+
].join(":");
|
|
716
|
+
|
|
717
|
+
const codes: number[] = [0];
|
|
718
|
+
if (bold) codes.push(1);
|
|
719
|
+
if (dim) codes.push(2);
|
|
720
|
+
if (italic) codes.push(3);
|
|
721
|
+
if (underline) codes.push(4);
|
|
722
|
+
if (blink) codes.push(5);
|
|
723
|
+
if (invisible) codes.push(8);
|
|
724
|
+
if (strike) codes.push(9);
|
|
725
|
+
if (overline) codes.push(53);
|
|
726
|
+
if (inverseUsesDefault) codes.push(7);
|
|
727
|
+
|
|
728
|
+
const pushPalette = (value: number, base: number, brightBase: number, isFg: boolean) => {
|
|
729
|
+
if (value < 8) {
|
|
730
|
+
codes.push(base + value);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (value < 16) {
|
|
734
|
+
codes.push(brightBase + (value - 8));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
codes.push(isFg ? 38 : 48, 5, value);
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
if (fgMode === "default") {
|
|
741
|
+
if (defaultFg) {
|
|
742
|
+
codes.push(38, 2, defaultFg.r, defaultFg.g, defaultFg.b);
|
|
743
|
+
} else {
|
|
744
|
+
codes.push(39);
|
|
745
|
+
}
|
|
746
|
+
} else if (fgMode === "palette") {
|
|
747
|
+
pushPalette(fg, 30, 90, true);
|
|
748
|
+
} else {
|
|
749
|
+
const r = (fg >> 16) & 0xff;
|
|
750
|
+
const g = (fg >> 8) & 0xff;
|
|
751
|
+
const b = fg & 0xff;
|
|
752
|
+
codes.push(38, 2, r, g, b);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (bgMode === "default") {
|
|
756
|
+
if (defaultBg) {
|
|
757
|
+
codes.push(48, 2, defaultBg.r, defaultBg.g, defaultBg.b);
|
|
758
|
+
} else {
|
|
759
|
+
codes.push(49);
|
|
760
|
+
}
|
|
761
|
+
} else if (bgMode === "palette") {
|
|
762
|
+
pushPalette(bg, 40, 100, false);
|
|
763
|
+
} else {
|
|
764
|
+
const r = (bg >> 16) & 0xff;
|
|
765
|
+
const g = (bg >> 8) & 0xff;
|
|
766
|
+
const b = bg & 0xff;
|
|
767
|
+
codes.push(48, 2, r, g, b);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
key,
|
|
772
|
+
ansi: `\x1b[${codes.join(";")}m`
|
|
773
|
+
};
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const renderLine = (lineIndex: number, cursorRow: number, cursorCol: number) => {
|
|
777
|
+
const buffer = terminal.buffer.active;
|
|
778
|
+
const base = viewY;
|
|
779
|
+
const isCursorLine = base + lineIndex === cursorRow;
|
|
780
|
+
const line = buffer.getLine(base + lineIndex);
|
|
781
|
+
const blankLine = " ".repeat(localMetrics.viewportCols);
|
|
782
|
+
if (!line) {
|
|
783
|
+
return blankLine;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const cell = buffer.getNullCell();
|
|
787
|
+
let output = "";
|
|
788
|
+
let currentKey = "default";
|
|
789
|
+
|
|
790
|
+
for (let col = 0; col < localMetrics.viewportCols; col++) {
|
|
791
|
+
const remoteCol = viewX + col;
|
|
792
|
+
if (remoteCol >= remoteCols) {
|
|
793
|
+
if (currentKey !== "default") {
|
|
794
|
+
output += "\x1b[0m";
|
|
795
|
+
currentKey = "default";
|
|
796
|
+
}
|
|
797
|
+
output += " ";
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const cellData = line.getCell(remoteCol, cell);
|
|
801
|
+
const isCursorCell = isCursorLine && remoteCol === cursorCol;
|
|
802
|
+
if (!cellData) {
|
|
803
|
+
if (currentKey !== "default") {
|
|
804
|
+
output += "\x1b[0m";
|
|
805
|
+
currentKey = "default";
|
|
806
|
+
}
|
|
807
|
+
output += " ";
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
let width = cellData.getWidth();
|
|
812
|
+
let chars = cellData.getChars();
|
|
813
|
+
if (!chars) {
|
|
814
|
+
chars = " ";
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (width === 0 && !isCursorCell) {
|
|
818
|
+
if (currentKey !== "default") {
|
|
819
|
+
output += "\x1b[0m";
|
|
820
|
+
currentKey = "default";
|
|
821
|
+
}
|
|
822
|
+
output += " ";
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (width === 0 && isCursorCell) {
|
|
826
|
+
width = 1;
|
|
827
|
+
chars = " ";
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (width === 2 && col === localMetrics.viewportCols - 1) {
|
|
831
|
+
if (currentKey !== "default") {
|
|
832
|
+
output += "\x1b[0m";
|
|
833
|
+
currentKey = "default";
|
|
834
|
+
}
|
|
835
|
+
output += " ";
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const style = styleKeyForCell(
|
|
840
|
+
cellData as ReturnType<typeof terminal.buffer.active.getNullCell>,
|
|
841
|
+
isCursorCell
|
|
842
|
+
);
|
|
843
|
+
if (style.key !== currentKey) {
|
|
844
|
+
output += style.ansi;
|
|
845
|
+
currentKey = style.key;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
output += chars;
|
|
849
|
+
if (width === 2) {
|
|
850
|
+
col += 1;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (currentKey !== "default") {
|
|
855
|
+
output += "\x1b[0m";
|
|
856
|
+
}
|
|
857
|
+
output += "\x1b[K";
|
|
858
|
+
return output;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
const render = () => {
|
|
862
|
+
if (!process.stdout.isTTY) return;
|
|
863
|
+
initUi();
|
|
864
|
+
|
|
865
|
+
localMetrics = getLocalMetrics();
|
|
866
|
+
clampView();
|
|
867
|
+
|
|
868
|
+
if (localMetrics.cols !== lastRenderCols || localMetrics.rows !== lastRenderRows) {
|
|
869
|
+
process.stdout.write("\x1b[2J");
|
|
870
|
+
lastRenderCols = localMetrics.cols;
|
|
871
|
+
lastRenderRows = localMetrics.rows;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const presenceNames = presence.filter((client) => client.id !== clientId).map((client) => client.name);
|
|
875
|
+
const statusLabel = status === "connected" ? "" : status;
|
|
876
|
+
const shareUrl = buildShareUrl();
|
|
877
|
+
|
|
878
|
+
const statusOrUrl = statusLabel || shareUrl;
|
|
879
|
+
const bottomLeft = `● ${config.room}`;
|
|
880
|
+
const autofitLabel = syncSize ? "autofit on" : "autofit off";
|
|
881
|
+
const peerLabel = `peers ${presenceNames.length}`;
|
|
882
|
+
const rightPrimary = decorateBottom(
|
|
883
|
+
[statusOrUrl, autofitLabel, peerLabel].filter(Boolean).join(" | "),
|
|
884
|
+
statusLabel ? [peerLabel] : [shareUrl, peerLabel]
|
|
885
|
+
);
|
|
886
|
+
const bottomPrimary = renderBar(
|
|
887
|
+
decorateTop(composeBar(bottomLeft, rightPrimary, localMetrics.cols), statusLabel ? [] : [shareUrl], [config.room]),
|
|
888
|
+
"bottom"
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
const noticeText = notice && notice.expiresAt > Date.now() ? ` ${notice.message} ` : "";
|
|
892
|
+
const controls = `${keyLabelShort}: ←→↑↓ pan, 0 center, A fit, M mouse | Ctrl: T hints, G detach, Q kill`;
|
|
893
|
+
const hintLine = renderBar(
|
|
894
|
+
composeBar(noticeText || `${BAR_DIM}${controls}${BAR_RESET_FG}`, "", localMetrics.cols),
|
|
895
|
+
"bottom"
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
const buffer = terminal.buffer.active;
|
|
899
|
+
const showCursor = connected && cursorVisible;
|
|
900
|
+
const cursorRow = showCursor ? buffer.baseY + buffer.cursorY : -1;
|
|
901
|
+
const cursorCol = showCursor ? buffer.cursorX : -1;
|
|
902
|
+
|
|
903
|
+
const lines: string[] = [];
|
|
904
|
+
for (let row = 0; row < localMetrics.viewportRows; row++) {
|
|
905
|
+
lines.push(renderLine(row, cursorRow, cursorCol));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (localMetrics.showBottomBar) {
|
|
909
|
+
lines.push(bottomPrimary);
|
|
910
|
+
}
|
|
911
|
+
if (localMetrics.showHintBar) {
|
|
912
|
+
lines.push(hintLine);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const output = "\x1b[?25l\x1b[H" + lines.join("\n");
|
|
916
|
+
process.stdout.write(output);
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const scheduleReconnect = () => {
|
|
920
|
+
if (!shouldReconnect) return;
|
|
921
|
+
|
|
922
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 30000);
|
|
923
|
+
reconnectAttempt += 1;
|
|
924
|
+
pushNotice(`Reconnecting in ${Math.round(delay / 1000)}s...`);
|
|
925
|
+
|
|
926
|
+
reconnectTimer = setTimeout(() => {
|
|
927
|
+
reconnectTimer = null;
|
|
928
|
+
if (shouldReconnect) {
|
|
929
|
+
connect();
|
|
930
|
+
}
|
|
931
|
+
}, delay);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const connect = () => {
|
|
935
|
+
if (reconnectTimer) {
|
|
936
|
+
clearTimeout(reconnectTimer);
|
|
937
|
+
reconnectTimer = null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const url = getUrl();
|
|
941
|
+
ws = new WebSocket(url);
|
|
942
|
+
setStatus("connecting");
|
|
943
|
+
|
|
944
|
+
ws.on("open", () => {
|
|
945
|
+
connected = true;
|
|
946
|
+
reconnectAttempt = 0;
|
|
947
|
+
setStatus("connected");
|
|
948
|
+
initUi();
|
|
949
|
+
if (process.stdin.isTTY) {
|
|
950
|
+
process.stdin.setRawMode(true);
|
|
951
|
+
}
|
|
952
|
+
process.stdin.resume();
|
|
953
|
+
if (syncSize) {
|
|
954
|
+
applySyncSize();
|
|
955
|
+
}
|
|
956
|
+
scheduleRender();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
ws.on("message", (data) => {
|
|
960
|
+
const message = safeParseServerMessage(data.toString());
|
|
961
|
+
if (!message) return;
|
|
962
|
+
|
|
963
|
+
switch (message.type) {
|
|
964
|
+
case "hello":
|
|
965
|
+
clientId = message.clientId;
|
|
966
|
+
collabMode = message.collabMode;
|
|
967
|
+
controllerId = message.controllerId;
|
|
968
|
+
scheduleRender();
|
|
969
|
+
break;
|
|
970
|
+
case "presence":
|
|
971
|
+
presence = message.clients;
|
|
972
|
+
scheduleRender();
|
|
973
|
+
break;
|
|
974
|
+
case "output":
|
|
975
|
+
{
|
|
976
|
+
trackOscColors(message.data);
|
|
977
|
+
const filtered = filterFocusSequences(message.data);
|
|
978
|
+
if (!filtered) return;
|
|
979
|
+
// Follow output if cursor was visible (standard terminal behavior)
|
|
980
|
+
const buffer = terminal.buffer.active;
|
|
981
|
+
const cursorRowBefore = buffer.baseY + buffer.cursorY;
|
|
982
|
+
const cursorWasVisible = cursorRowBefore >= viewY && cursorRowBefore < viewY + localMetrics.viewportRows;
|
|
983
|
+
terminal.write(filtered, () => {
|
|
984
|
+
if (cursorWasVisible) {
|
|
985
|
+
// Only scroll if cursor moved outside viewport (with scrollOff margin)
|
|
986
|
+
const cursorRowAfter = buffer.baseY + buffer.cursorY;
|
|
987
|
+
const margin = Math.min(scrollOff, Math.floor(localMetrics.viewportRows / 2));
|
|
988
|
+
if (cursorRowAfter < viewY + margin) {
|
|
989
|
+
viewY = Math.max(0, cursorRowAfter - margin);
|
|
990
|
+
} else if (cursorRowAfter >= viewY + localMetrics.viewportRows - margin) {
|
|
991
|
+
viewY = cursorRowAfter - localMetrics.viewportRows + margin + 1;
|
|
992
|
+
}
|
|
993
|
+
clampView();
|
|
994
|
+
}
|
|
995
|
+
scheduleRender();
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
break;
|
|
999
|
+
case "snapshot":
|
|
1000
|
+
{
|
|
1001
|
+
trackOscColors(message.data);
|
|
1002
|
+
terminal.clear();
|
|
1003
|
+
const filtered = filterFocusSequences(message.data);
|
|
1004
|
+
if (!filtered) return;
|
|
1005
|
+
terminal.write(filtered, () => {
|
|
1006
|
+
// On snapshot, only scroll if cursor is outside viewport (with scrollOff margin)
|
|
1007
|
+
const buffer = terminal.buffer.active;
|
|
1008
|
+
const cursorRow = buffer.baseY + buffer.cursorY;
|
|
1009
|
+
const margin = Math.min(scrollOff, Math.floor(localMetrics.viewportRows / 2));
|
|
1010
|
+
if (cursorRow < viewY + margin) {
|
|
1011
|
+
viewY = Math.max(0, cursorRow - margin);
|
|
1012
|
+
} else if (cursorRow >= viewY + localMetrics.viewportRows - margin) {
|
|
1013
|
+
viewY = cursorRow - localMetrics.viewportRows + margin + 1;
|
|
1014
|
+
}
|
|
1015
|
+
clampView();
|
|
1016
|
+
scheduleRender();
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
break;
|
|
1020
|
+
case "collab":
|
|
1021
|
+
collabMode = message.enabled;
|
|
1022
|
+
controllerId = message.controllerId;
|
|
1023
|
+
pushNotice(message.enabled ? "Collaborative typing enabled" : "Control locked");
|
|
1024
|
+
break;
|
|
1025
|
+
case "input_rejected":
|
|
1026
|
+
pushNotice(message.reason);
|
|
1027
|
+
break;
|
|
1028
|
+
case "active_size":
|
|
1029
|
+
applyRemoteSize(message.cols, message.rows);
|
|
1030
|
+
break;
|
|
1031
|
+
case "session_ended":
|
|
1032
|
+
shouldReconnect = false;
|
|
1033
|
+
exitMessage = message.message;
|
|
1034
|
+
exitCode = 0;
|
|
1035
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
1036
|
+
ws.close();
|
|
1037
|
+
} else {
|
|
1038
|
+
cleanupAndExit();
|
|
1039
|
+
}
|
|
1040
|
+
break;
|
|
1041
|
+
case "error":
|
|
1042
|
+
pushNotice(message.message);
|
|
1043
|
+
break;
|
|
1044
|
+
default:
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
ws.on("close", () => {
|
|
1050
|
+
connected = false;
|
|
1051
|
+
if (shouldReconnect) {
|
|
1052
|
+
setStatus("reconnecting");
|
|
1053
|
+
scheduleReconnect();
|
|
1054
|
+
} else {
|
|
1055
|
+
cleanupAndExit();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
ws.on("error", (err) => {
|
|
1060
|
+
if (!shouldReconnect) {
|
|
1061
|
+
pushNotice(`Connection error: ${err.message}`);
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const cleanupAndExit = () => {
|
|
1067
|
+
if (typingTimeout) {
|
|
1068
|
+
clearTimeout(typingTimeout);
|
|
1069
|
+
}
|
|
1070
|
+
if (reconnectTimer) {
|
|
1071
|
+
clearTimeout(reconnectTimer);
|
|
1072
|
+
}
|
|
1073
|
+
if (noticeTimer) {
|
|
1074
|
+
clearTimeout(noticeTimer);
|
|
1075
|
+
}
|
|
1076
|
+
if (process.stdin.isTTY) {
|
|
1077
|
+
process.stdin.setRawMode(false);
|
|
1078
|
+
}
|
|
1079
|
+
process.stdin.pause();
|
|
1080
|
+
restoreUi();
|
|
1081
|
+
if (exitMessage) {
|
|
1082
|
+
console.log(`[hay] ${exitMessage}`);
|
|
1083
|
+
} else if (exitCode === DETACH_EXIT_CODE) {
|
|
1084
|
+
console.log("[hay] Detaching (session continues in background)...");
|
|
1085
|
+
} else if (exitCode === KILL_EXIT_CODE) {
|
|
1086
|
+
console.log("[hay] Session terminated.");
|
|
1087
|
+
} else {
|
|
1088
|
+
console.log("[hay] Disconnected");
|
|
1089
|
+
}
|
|
1090
|
+
process.exit(exitCode);
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const handleLocalShortcut = (input: string) => {
|
|
1094
|
+
if (!input) return false;
|
|
1095
|
+
|
|
1096
|
+
const shouldExitImmediately =
|
|
1097
|
+
!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING;
|
|
1098
|
+
|
|
1099
|
+
// Ctrl+Q to kill session
|
|
1100
|
+
if (input === "\x11") {
|
|
1101
|
+
shouldReconnect = false;
|
|
1102
|
+
exitCode = KILL_EXIT_CODE;
|
|
1103
|
+
if (reconnectTimer) {
|
|
1104
|
+
clearTimeout(reconnectTimer);
|
|
1105
|
+
}
|
|
1106
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
1107
|
+
sendMessage({ type: "kill_session" });
|
|
1108
|
+
setTimeout(() => ws?.close(), 100);
|
|
1109
|
+
} else if (shouldExitImmediately) {
|
|
1110
|
+
cleanupAndExit();
|
|
1111
|
+
}
|
|
1112
|
+
return true;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Ctrl+G to detach
|
|
1116
|
+
if (input === "\x07") {
|
|
1117
|
+
shouldReconnect = false;
|
|
1118
|
+
exitCode = DETACH_EXIT_CODE;
|
|
1119
|
+
if (reconnectTimer) {
|
|
1120
|
+
clearTimeout(reconnectTimer);
|
|
1121
|
+
}
|
|
1122
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
1123
|
+
ws.close();
|
|
1124
|
+
} else if (shouldExitImmediately) {
|
|
1125
|
+
cleanupAndExit();
|
|
1126
|
+
}
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Ctrl+T toggles hint bar
|
|
1131
|
+
if (input === "\x14") {
|
|
1132
|
+
showHints = !showHints;
|
|
1133
|
+
saveConfig({ ...configFile, showHints });
|
|
1134
|
+
pushNotice(`Hints ${showHints ? "on" : "off"}`);
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const altKey = (key: string) => input === `\x1b${key}`;
|
|
1139
|
+
const altArrow = (seq: string) => input === seq;
|
|
1140
|
+
const optionChar = (char: string) => input === char;
|
|
1141
|
+
|
|
1142
|
+
if (altKey("h") || altKey("H") || altArrow("\x1b[1;3D") || altArrow("\x1b\x1b[D")) {
|
|
1143
|
+
const step = altKey("H") ? PAN_FAST_STEP : PAN_STEP;
|
|
1144
|
+
viewX -= step;
|
|
1145
|
+
clampView();
|
|
1146
|
+
scheduleRender();
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
if (altKey("l") || altKey("L") || altArrow("\x1b[1;3C") || altArrow("\x1b\x1b[C")) {
|
|
1150
|
+
const step = altKey("L") ? PAN_FAST_STEP : PAN_STEP;
|
|
1151
|
+
viewX += step;
|
|
1152
|
+
clampView();
|
|
1153
|
+
scheduleRender();
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
if (altKey("k") || altKey("K") || altArrow("\x1b[1;3A") || altArrow("\x1b\x1b[A")) {
|
|
1157
|
+
const step = altKey("K") ? PAN_FAST_STEP : PAN_STEP;
|
|
1158
|
+
viewY -= step;
|
|
1159
|
+
clampView();
|
|
1160
|
+
scheduleRender();
|
|
1161
|
+
return true;
|
|
1162
|
+
}
|
|
1163
|
+
if (altKey("j") || altKey("J") || altArrow("\x1b[1;3B") || altArrow("\x1b\x1b[B")) {
|
|
1164
|
+
const step = altKey("J") ? PAN_FAST_STEP : PAN_STEP;
|
|
1165
|
+
viewY += step;
|
|
1166
|
+
clampView();
|
|
1167
|
+
scheduleRender();
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
if (altKey("a") || altKey("A") || optionChar("å")) {
|
|
1171
|
+
syncSize = !syncSize;
|
|
1172
|
+
pushNotice(`Autofit ${syncSize ? "on" : "off"}`);
|
|
1173
|
+
if (syncSize) {
|
|
1174
|
+
applySyncSize();
|
|
1175
|
+
}
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
if (altKey("m") || altKey("M") || optionChar("µ")) {
|
|
1179
|
+
setMouseCapture(!mouseCapture);
|
|
1180
|
+
pushNotice(`Mouse capture ${mouseCapture ? "on" : "off"}`);
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
if (altKey("0") || optionChar("º")) {
|
|
1184
|
+
ensureCursorVisible(true);
|
|
1185
|
+
pushNotice("Centered on cursor");
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
return false;
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
process.stdin.on("data", (data) => {
|
|
1193
|
+
let input = pendingInput + data.toString();
|
|
1194
|
+
pendingInput = "";
|
|
1195
|
+
const lastMouseStart = input.lastIndexOf("\x1b[<");
|
|
1196
|
+
if (lastMouseStart !== -1) {
|
|
1197
|
+
const tail = input.slice(lastMouseStart);
|
|
1198
|
+
if (!/[Mm]/.test(tail)) {
|
|
1199
|
+
pendingInput = tail;
|
|
1200
|
+
input = input.slice(0, lastMouseStart);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (!input) return;
|
|
1204
|
+
const mouseResult = stripMouseSequences(input);
|
|
1205
|
+
input = mouseResult.cleaned;
|
|
1206
|
+
if (!input) return;
|
|
1207
|
+
if (handleLocalShortcut(input)) return;
|
|
1208
|
+
|
|
1209
|
+
const sanitized = filterFocusSequences(input);
|
|
1210
|
+
if (!sanitized) return;
|
|
1211
|
+
|
|
1212
|
+
if (syncSize) {
|
|
1213
|
+
applySyncSize();
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
sendMessage({ type: "input", data: sanitized });
|
|
1217
|
+
handleTyping();
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
process.stdout.on("resize", () => {
|
|
1221
|
+
localMetrics = getLocalMetrics();
|
|
1222
|
+
if (syncSize) {
|
|
1223
|
+
applySyncSize();
|
|
1224
|
+
} else {
|
|
1225
|
+
clampView();
|
|
1226
|
+
}
|
|
1227
|
+
scheduleRender();
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
process.on("SIGINT", () => {
|
|
1231
|
+
shouldReconnect = false;
|
|
1232
|
+
if (reconnectTimer) {
|
|
1233
|
+
clearTimeout(reconnectTimer);
|
|
1234
|
+
}
|
|
1235
|
+
ws?.close();
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
process.on("SIGTERM", () => {
|
|
1239
|
+
shouldReconnect = false;
|
|
1240
|
+
if (reconnectTimer) {
|
|
1241
|
+
clearTimeout(reconnectTimer);
|
|
1242
|
+
}
|
|
1243
|
+
ws?.close();
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
process.on("exit", () => {
|
|
1247
|
+
restoreUi();
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
connect();
|