hop2 0.10.0 → 0.11.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/README-MCP.md +70 -36
- package/README.md +23 -8
- package/hay/CHANGELOG.md +31 -1
- package/hay/README.md +53 -8
- package/hay/apps/cli/package.json +1 -1
- package/hay/apps/cli/src/index.ts +1097 -135
- package/hay/apps/server/package.json +1 -1
- package/hay/apps/server/src/rooms.ts +68 -14
- package/hay/apps/server/test/rooms.test.ts +75 -0
- package/hay/apps/web/package.json +1 -1
- package/hay/apps/web/src/App.tsx +512 -111
- package/hay/apps/web/src/components/MobileKeyboard.tsx +32 -11
- package/hay/apps/web/src/styles.css +254 -68
- package/hay/package-lock.json +0 -1
- package/hay/packages/shared/package.json +1 -1
- package/hay/packages/shared/src/protocol.ts +8 -2
- package/hay/packages/shared/test/protocol.test.ts +32 -0
- package/hay-web/assets/index-BzJB9r_I.js +64 -0
- package/hay-web/assets/{index-D9aOC5Zt.js.map → index-BzJB9r_I.js.map} +1 -1
- package/hay-web/assets/index-DG8Ntznc.css +32 -0
- package/hay-web/index.html +2 -2
- package/hop +977 -140
- package/lib/naming-themes.js +28 -14
- package/mcp/hop-mcp.js +380 -90
- package/package.json +1 -1
- package/scripts/hay-host.js +8 -1
- package/sessions.html +152 -21
- package/setup-mcp.js +37 -19
- package/hay-web/assets/index-BmWjq_Nh.css +0 -32
- package/hay-web/assets/index-D9aOC5Zt.js +0 -63
|
@@ -12,7 +12,15 @@ const isMac = process.platform === "darwin";
|
|
|
12
12
|
const keyLabelShort = isMac ? "Opt" : "Alt";
|
|
13
13
|
const keyLabelLong = isMac ? "Option" : "Alt";
|
|
14
14
|
|
|
15
|
-
type
|
|
15
|
+
type ThemeName = "auto" | "light" | "dark";
|
|
16
|
+
type CliConfig = {
|
|
17
|
+
showHints?: boolean;
|
|
18
|
+
showStatusBar?: boolean;
|
|
19
|
+
mouseCapture?: boolean;
|
|
20
|
+
syncSize?: boolean;
|
|
21
|
+
scrollOff?: number;
|
|
22
|
+
theme?: ThemeName;
|
|
23
|
+
};
|
|
16
24
|
type HopConfig = { "hay-cli"?: CliConfig };
|
|
17
25
|
const DEFAULT_SCROLL_OFF = 3;
|
|
18
26
|
|
|
@@ -115,6 +123,7 @@ function parseArgs() {
|
|
|
115
123
|
let server = "ws://localhost:4001/ws";
|
|
116
124
|
let room = "";
|
|
117
125
|
let name = process.env.USER || "cli-user";
|
|
126
|
+
let theme: ThemeName | undefined;
|
|
118
127
|
|
|
119
128
|
for (let i = 0; i < args.length; i++) {
|
|
120
129
|
const arg = args[i];
|
|
@@ -124,6 +133,13 @@ function parseArgs() {
|
|
|
124
133
|
room = args[++i] || room;
|
|
125
134
|
} else if (arg === "--name" || arg === "-n") {
|
|
126
135
|
name = args[++i] || name;
|
|
136
|
+
} else if (arg === "--theme") {
|
|
137
|
+
const value = (args[++i] || "").toLowerCase();
|
|
138
|
+
if (value !== "auto" && value !== "light" && value !== "dark") {
|
|
139
|
+
console.error(`Invalid --theme value: ${value || "(none)"}. Use auto, light, or dark.`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
theme = value;
|
|
127
143
|
} else if (arg === "--help" || arg === "-h") {
|
|
128
144
|
console.log(`
|
|
129
145
|
hay-cli - Connect to a hay room from your terminal
|
|
@@ -135,26 +151,50 @@ Options:
|
|
|
135
151
|
-s, --server <url> WebSocket server URL (default: ws://localhost:4001/ws)
|
|
136
152
|
-r, --room <name> Room name (required)
|
|
137
153
|
-n, --name <name> Display name (default: $USER)
|
|
154
|
+
--theme <mode> Bar theme: auto (default; matches the terminal background),
|
|
155
|
+
light, or dark. Persist it in .hop.json:
|
|
156
|
+
{"hay-cli": {"theme": "dark"}}
|
|
138
157
|
-h, --help Show this help
|
|
139
158
|
|
|
140
159
|
Keyboard shortcuts:
|
|
141
|
-
Ctrl+G Detach (session
|
|
142
|
-
Ctrl+Q
|
|
143
|
-
|
|
144
|
-
${keyLabelLong}
|
|
145
|
-
${keyLabelLong}+
|
|
160
|
+
Ctrl+G Detach (session keeps running in the background)
|
|
161
|
+
Ctrl+Q Ctrl+Q Kill session for ALL participants (press twice to confirm)
|
|
162
|
+
${keyLabelLong}+←/→/↑/↓ Pan viewport (add Shift for faster panning)
|
|
163
|
+
${keyLabelLong}+H/J/K/L Pan viewport, vim-style (Shift = faster)
|
|
164
|
+
${keyLabelLong}+0 Return to live output (center on cursor)
|
|
165
|
+
${keyLabelLong}+A Toggle autofit of remote size to window (saved)
|
|
166
|
+
${keyLabelLong}+B Toggle status bar (saved)
|
|
167
|
+
${keyLabelLong}+M Toggle mouse capture (saved)
|
|
168
|
+
${keyLabelLong}+C Take/release exclusive control
|
|
169
|
+
${keyLabelLong}+F Search scrollback (Enter/↓ next, ↑ prev, Esc close)
|
|
170
|
+
${keyLabelLong}+T Toggle hint bar (saved)
|
|
171
|
+
${keyLabelLong}+\\ Send the next key literally to the remote terminal
|
|
172
|
+
(lets reserved keys like Ctrl+Q/G reach remote programs)
|
|
173
|
+
|
|
174
|
+
${keyLabelLong}+<key> shortcuts arrive as ESC-prefixed keys; on non-macOS terminals
|
|
175
|
+
(and macOS terminals without "Option as Meta") enable option/alt-as-meta or
|
|
176
|
+
ESC-prefix in your terminal settings for them to work.
|
|
177
|
+
|
|
178
|
+
Sessions persist on the server when you detach or the client exits;
|
|
179
|
+
reattach with: hay -r <room>. Only Ctrl+Q (twice) ends the session itself.
|
|
146
180
|
|
|
147
181
|
Examples:
|
|
148
182
|
hay -r my-room
|
|
149
183
|
hay -r my-room -n alice -s ws://example.com/ws
|
|
150
184
|
`);
|
|
151
185
|
process.exit(0);
|
|
186
|
+
} else if (arg.startsWith("-")) {
|
|
187
|
+
console.error(`Unknown option: ${arg}. Run hay --help`);
|
|
188
|
+
process.exit(1);
|
|
152
189
|
} else if (!room) {
|
|
153
190
|
room = arg;
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`Unexpected argument: ${arg} (room is already '${room}'). Run hay --help`);
|
|
193
|
+
process.exit(1);
|
|
154
194
|
}
|
|
155
195
|
}
|
|
156
196
|
|
|
157
|
-
return { server, room, name };
|
|
197
|
+
return { server, room, name, theme };
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
const config = parseArgs();
|
|
@@ -170,6 +210,12 @@ let liveCwd: string | null = null;
|
|
|
170
210
|
|
|
171
211
|
const DETACH_EXIT_CODE = 10;
|
|
172
212
|
const KILL_EXIT_CODE = 11;
|
|
213
|
+
// Ctrl+Q while the server is unreachable: nothing was killed, so exit on a
|
|
214
|
+
// distinct code instead of pretending the session ended.
|
|
215
|
+
const KILL_UNREACHABLE_EXIT_CODE = 12;
|
|
216
|
+
const KILL_CONFIRM_MS = 2000;
|
|
217
|
+
// Give up if the very first connection never succeeds after this many attempts.
|
|
218
|
+
const MAX_NEVER_CONNECTED_ATTEMPTS = 5;
|
|
173
219
|
const TYPING_IDLE_MS = 1200;
|
|
174
220
|
const NOTICE_MS = 3500;
|
|
175
221
|
const HOP_DAEMON_POLL_MS = 5000;
|
|
@@ -199,34 +245,164 @@ let status: "connecting" | "connected" | "reconnecting" | "disconnected" = "conn
|
|
|
199
245
|
let shouldReconnect = true;
|
|
200
246
|
let reconnectAttempt = 0;
|
|
201
247
|
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
248
|
+
let nextReconnectAt = 0;
|
|
249
|
+
let reconnectTicker: NodeJS.Timeout | null = null;
|
|
202
250
|
let typingTimeout: NodeJS.Timeout | null = null;
|
|
203
251
|
let noticeTimer: NodeJS.Timeout | null = null;
|
|
204
252
|
let daemonPollTimer: NodeJS.Timeout | null = null;
|
|
205
253
|
let exitCode = 0;
|
|
206
254
|
let exitMessage: string | null = null;
|
|
207
255
|
let shortcutsEnabledAt = 0;
|
|
256
|
+
let attachNoticeShown = false;
|
|
257
|
+
let pendingAttachNotice = false;
|
|
258
|
+
let everConnected = false;
|
|
259
|
+
let lastWsError: string | null = null;
|
|
260
|
+
let killArmedAt = 0; // Ctrl+Q pressed once; second press before this kills
|
|
261
|
+
let killRequested = false; // we sent kill_session ourselves
|
|
262
|
+
let literalNext = false; // Opt+\ armed: forward the next key verbatim
|
|
208
263
|
|
|
209
264
|
let collabMode = true;
|
|
210
265
|
let controllerId: string | null = null;
|
|
211
266
|
let presence: PresenceClient[] = [];
|
|
212
|
-
|
|
267
|
+
type NoticeKind = "info" | "ok" | "warn";
|
|
268
|
+
let notice: { message: string; kind: NoticeKind; expiresAt: number } | null = null;
|
|
213
269
|
|
|
214
270
|
let syncSize = true;
|
|
215
|
-
let mouseCapture =
|
|
271
|
+
let mouseCapture = false;
|
|
272
|
+
let showStatusBar = true;
|
|
216
273
|
let viewX = 0;
|
|
217
274
|
let viewY = 0;
|
|
218
275
|
let followOutput = true;
|
|
219
276
|
let showHints = true;
|
|
220
277
|
let scrollOff = DEFAULT_SCROLL_OFF;
|
|
221
278
|
|
|
222
|
-
|
|
279
|
+
// Mouse selection state
|
|
280
|
+
type SelectionPoint = { row: number; col: number }; // buffer coordinates
|
|
281
|
+
let selectionAnchor: SelectionPoint | null = null; // where drag started
|
|
282
|
+
let selectionEnd: SelectionPoint | null = null; // where drag currently is
|
|
283
|
+
let isSelecting = false;
|
|
284
|
+
let selectionScrollTimer: NodeJS.Timeout | null = null;
|
|
285
|
+
const SELECTION_SCROLL_INTERVAL = 50;
|
|
286
|
+
const SELECTION_SCROLL_SPEED = 1;
|
|
287
|
+
|
|
288
|
+
// Scrollback search state (Opt+F). searchMatches are absolute buffer coords;
|
|
289
|
+
// col is the match start in the line's collapsed text (≈ buffer column for ASCII).
|
|
290
|
+
let searchMode = false;
|
|
291
|
+
let searchQuery = "";
|
|
292
|
+
let searchMatches: Array<{ row: number; col: number }> = [];
|
|
293
|
+
let searchMatchesByRow = new Map<number, number[]>(); // row → match start cols, for O(1) render lookups
|
|
294
|
+
let searchIndex = -1;
|
|
295
|
+
const SEARCH_MAX_MATCHES = 2000;
|
|
296
|
+
|
|
297
|
+
let configFile = loadConfig();
|
|
298
|
+
const persistConfig = (patch: Partial<CliConfig>) => {
|
|
299
|
+
configFile = { ...configFile, ...patch };
|
|
300
|
+
saveConfig(configFile);
|
|
301
|
+
};
|
|
223
302
|
showHints = configFile.showHints ?? true;
|
|
303
|
+
showStatusBar = configFile.showStatusBar ?? true;
|
|
304
|
+
mouseCapture = configFile.mouseCapture ?? false;
|
|
305
|
+
syncSize = configFile.syncSize ?? true;
|
|
224
306
|
scrollOff = configFile.scrollOff ?? DEFAULT_SCROLL_OFF;
|
|
225
307
|
|
|
226
308
|
let uiInitialized = false;
|
|
227
309
|
let renderScheduled = false;
|
|
228
310
|
let lastRenderCols = 0;
|
|
229
311
|
let lastRenderRows = 0;
|
|
312
|
+
let lastRenderedLines: string[] = []; // previous frame's lines, for dirty-line diffing
|
|
313
|
+
|
|
314
|
+
// ---- Bar theme -------------------------------------------------------------
|
|
315
|
+
// The bars adapt to the terminal: "auto" (default) resolves light/dark from the
|
|
316
|
+
// terminal's reported background (OSC 11 query, with $COLORFGBG as an immediate
|
|
317
|
+
// guess until the reply arrives); "light"/"dark" pin a palette. Configure with
|
|
318
|
+
// --theme or {"hay-cli": {"theme": "dark"}} in .hop.json. Colors are truecolor
|
|
319
|
+
// when $COLORTERM advertises it, with 256-color fallbacks otherwise.
|
|
320
|
+
type BarColor = { rgb: [number, number, number]; c256: number };
|
|
321
|
+
type BarPalette = {
|
|
322
|
+
statusBg: BarColor; // status line surface
|
|
323
|
+
hintBg: BarColor; // hint line surface (one step fainter, for hierarchy)
|
|
324
|
+
fg: BarColor;
|
|
325
|
+
dim: BarColor;
|
|
326
|
+
accent: BarColor;
|
|
327
|
+
accentFg: BarColor; // text on accent (the session chip)
|
|
328
|
+
ok: BarColor;
|
|
329
|
+
warn: BarColor;
|
|
330
|
+
err: BarColor;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const LIGHT_PALETTE: BarPalette = {
|
|
334
|
+
statusBg: { rgb: [233, 233, 237], c256: 254 },
|
|
335
|
+
hintBg: { rgb: [242, 242, 247], c256: 255 },
|
|
336
|
+
fg: { rgb: [29, 29, 31], c256: 234 },
|
|
337
|
+
dim: { rgb: [110, 110, 115], c256: 242 },
|
|
338
|
+
accent: { rgb: [124, 58, 237], c256: 93 },
|
|
339
|
+
accentFg: { rgb: [255, 255, 255], c256: 15 },
|
|
340
|
+
ok: { rgb: [26, 127, 55], c256: 28 },
|
|
341
|
+
warn: { rgb: [180, 83, 9], c256: 130 },
|
|
342
|
+
err: { rgb: [207, 34, 46], c256: 160 }
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const DARK_PALETTE: BarPalette = {
|
|
346
|
+
statusBg: { rgb: [42, 42, 46], c256: 236 },
|
|
347
|
+
hintBg: { rgb: [35, 35, 39], c256: 235 },
|
|
348
|
+
fg: { rgb: [230, 230, 233], c256: 252 },
|
|
349
|
+
dim: { rgb: [147, 147, 155], c256: 245 },
|
|
350
|
+
accent: { rgb: [167, 139, 250], c256: 141 },
|
|
351
|
+
accentFg: { rgb: [29, 29, 31], c256: 234 },
|
|
352
|
+
ok: { rgb: [63, 185, 80], c256: 71 },
|
|
353
|
+
warn: { rgb: [210, 153, 34], c256: 178 },
|
|
354
|
+
err: { rgb: [248, 81, 73], c256: 203 }
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const supportsTruecolor = /truecolor|24bit/i.test(process.env.COLORTERM || "");
|
|
358
|
+
const fgAnsi = (color: BarColor) =>
|
|
359
|
+
supportsTruecolor ? `\x1b[38;2;${color.rgb.join(";")}m` : `\x1b[38;5;${color.c256}m`;
|
|
360
|
+
const bgAnsi = (color: BarColor) =>
|
|
361
|
+
supportsTruecolor ? `\x1b[48;2;${color.rgb.join(";")}m` : `\x1b[48;5;${color.c256}m`;
|
|
362
|
+
|
|
363
|
+
const makeBarStyles = (p: BarPalette) => ({
|
|
364
|
+
statusBg: bgAnsi(p.statusBg),
|
|
365
|
+
hintBg: bgAnsi(p.hintBg),
|
|
366
|
+
fg: fgAnsi(p.fg),
|
|
367
|
+
dim: fgAnsi(p.dim),
|
|
368
|
+
accent: fgAnsi(p.accent),
|
|
369
|
+
ok: fgAnsi(p.ok),
|
|
370
|
+
warn: fgAnsi(p.warn),
|
|
371
|
+
err: fgAnsi(p.err),
|
|
372
|
+
bold: "\x1b[1m",
|
|
373
|
+
// Restore default bar text after a bold/colored token (keeps the bar bg).
|
|
374
|
+
resetFg: `\x1b[22m${fgAnsi(p.fg)}`,
|
|
375
|
+
// Session chip: accent pill; callers restore statusBg+fg afterwards.
|
|
376
|
+
chip: `${bgAnsi(p.accent)}${fgAnsi(p.accentFg)}\x1b[1m`,
|
|
377
|
+
reconnect: `\x1b[1m${fgAnsi(p.warn)}`
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let BAR = makeBarStyles(LIGHT_PALETTE);
|
|
381
|
+
const applyResolvedTheme = (resolved: "light" | "dark") => {
|
|
382
|
+
BAR = makeBarStyles(resolved === "dark" ? DARK_PALETTE : LIGHT_PALETTE);
|
|
383
|
+
if (uiInitialized) {
|
|
384
|
+
lastRenderedLines = [];
|
|
385
|
+
scheduleRender();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// $COLORFGBG looks like "15;0" (fg;bg) — bg 0-6 and 8 mean a dark background.
|
|
390
|
+
const guessThemeFromEnv = (): "light" | "dark" | null => {
|
|
391
|
+
const raw = process.env.COLORFGBG;
|
|
392
|
+
if (!raw) return null;
|
|
393
|
+
const bg = Number(raw.split(";").pop());
|
|
394
|
+
if (!Number.isFinite(bg)) return null;
|
|
395
|
+
return bg <= 6 || bg === 8 ? "dark" : "light";
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const themePref: ThemeName = config.theme ?? configFile.theme ?? "auto";
|
|
399
|
+
let themeQueryDeadline = 0; // OSC 11 reply capture window (auto detection)
|
|
400
|
+
let themeQueryAsked = false;
|
|
401
|
+
if (themePref === "light" || themePref === "dark") {
|
|
402
|
+
applyResolvedTheme(themePref);
|
|
403
|
+
} else {
|
|
404
|
+
applyResolvedTheme(guessThemeFromEnv() ?? "light");
|
|
405
|
+
}
|
|
230
406
|
|
|
231
407
|
const hopHomeDir = process.env.HOP_HOME || path.join(os.homedir(), ".hop2");
|
|
232
408
|
const hopTunnelStateFile = path.join(hopHomeDir, ".tunnel-state");
|
|
@@ -271,8 +447,15 @@ let hopDaemonShareUrl = "";
|
|
|
271
447
|
const getLocalMetrics = () => {
|
|
272
448
|
const cols = process.stdout.columns || 80;
|
|
273
449
|
const rows = process.stdout.rows || 24;
|
|
274
|
-
const
|
|
275
|
-
const
|
|
450
|
+
const hasNotice = !!notice && notice.expiresAt > Date.now();
|
|
451
|
+
const showBottomBar = showStatusBar && rows >= 2;
|
|
452
|
+
// The persistent hint/controls line is subordinate to the status bar, so Opt+B
|
|
453
|
+
// clears the whole bottom chrome (both lines), not just the status line. A
|
|
454
|
+
// transient notice still flashes even with the status bar hidden; Opt+T still
|
|
455
|
+
// toggles just the hint line while the status bar is shown. The reconnect
|
|
456
|
+
// banner forces the line on regardless, so a dropped connection is never silent.
|
|
457
|
+
const reconnecting = !connected && reconnectAttempt > 0;
|
|
458
|
+
const showHintBar = ((showHints && showBottomBar) || hasNotice || reconnecting || searchMode) && rows >= (showBottomBar ? 3 : 2);
|
|
276
459
|
const barRows = (showBottomBar ? 1 : 0) + (showHintBar ? 1 : 0);
|
|
277
460
|
return {
|
|
278
461
|
cols,
|
|
@@ -293,13 +476,20 @@ let remoteRows = Math.max(1, localMetrics.viewportRows);
|
|
|
293
476
|
const terminal = new Terminal({
|
|
294
477
|
cols: remoteCols,
|
|
295
478
|
rows: remoteRows,
|
|
296
|
-
scrollback:
|
|
479
|
+
scrollback: 5000,
|
|
297
480
|
allowProposedApi: true
|
|
298
481
|
});
|
|
299
482
|
|
|
483
|
+
// True while replaying a snapshot's historical output into the terminal.
|
|
484
|
+
// Snapshots are raw PTY history that can contain DSR/DA/CPR/OSC queries; xterm
|
|
485
|
+
// answers them via onData, and forwarding those stale answers to the live PTY
|
|
486
|
+
// injects junk (e.g. "^[[1;9R") at the remote prompt on every reconnect.
|
|
487
|
+
let replayingSnapshot = false;
|
|
488
|
+
|
|
300
489
|
// Forward terminal-generated responses (eg. DSR cursor reports) back to the PTY.
|
|
301
490
|
terminal.onData((data) => {
|
|
302
491
|
if (!data) return;
|
|
492
|
+
if (replayingSnapshot) return;
|
|
303
493
|
sendMessage({ type: "input", data });
|
|
304
494
|
});
|
|
305
495
|
|
|
@@ -313,30 +503,198 @@ const getDirectionalKey = (direction: number) => {
|
|
|
313
503
|
};
|
|
314
504
|
|
|
315
505
|
const stripMouseSequences = (data: string) => {
|
|
316
|
-
const cleaned = data.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (sequence, buttonText,
|
|
506
|
+
const cleaned = data.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (sequence, buttonText, xText, yText, kind) => {
|
|
507
|
+
if (!mouseCapture) {
|
|
508
|
+
return "";
|
|
509
|
+
}
|
|
317
510
|
const button = Number(buttonText);
|
|
511
|
+
const x = Number(xText);
|
|
512
|
+
const y = Number(yText);
|
|
513
|
+
|
|
514
|
+
// Handle wheel events first (existing logic)
|
|
318
515
|
const isWheel = (button & 64) === 64;
|
|
319
516
|
if (isWheel && kind === "M") {
|
|
320
|
-
|
|
517
|
+
// Wheel left/right (buttons 66/67) arrive on touchpads with any
|
|
518
|
+
// horizontal drift; treating them as vertical makes scrolling bounce.
|
|
519
|
+
const wheelAxis = button & 3;
|
|
520
|
+
if (wheelAxis >= 2) {
|
|
521
|
+
return "";
|
|
522
|
+
}
|
|
523
|
+
// Wheel clears any active selection
|
|
524
|
+
clearSelection();
|
|
525
|
+
const direction = wheelAxis === 1 ? 1 : -1;
|
|
321
526
|
const isTuiLikeMode = remoteAlternateScreen || remoteMouseModes.size > 0;
|
|
322
527
|
if (isTuiLikeMode) {
|
|
323
528
|
return getDirectionalKey(direction).repeat(SCROLL_STEP);
|
|
324
529
|
}
|
|
325
|
-
// In normal shell mode, wheel should pan local viewport scrollback.
|
|
326
530
|
viewY += direction * SCROLL_STEP;
|
|
327
531
|
clampView();
|
|
328
532
|
updateFollowOutputFromViewport();
|
|
329
533
|
scheduleRender();
|
|
330
534
|
return "";
|
|
331
535
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
536
|
+
|
|
537
|
+
// Try local mouse selection handling
|
|
538
|
+
const result = handleMouseEvent(button, x, y, kind);
|
|
539
|
+
if (result === "handled") return "";
|
|
540
|
+
if (result === "passthrough") return sequence;
|
|
541
|
+
|
|
335
542
|
return "";
|
|
336
543
|
});
|
|
337
544
|
return { cleaned };
|
|
338
545
|
};
|
|
339
546
|
|
|
547
|
+
// ── Selection helpers ──
|
|
548
|
+
|
|
549
|
+
const selectionNormalized = (): { start: SelectionPoint; end: SelectionPoint } | null => {
|
|
550
|
+
if (!selectionAnchor || !selectionEnd) return null;
|
|
551
|
+
const a = selectionAnchor;
|
|
552
|
+
const b = selectionEnd;
|
|
553
|
+
if (a.row < b.row || (a.row === b.row && a.col <= b.col)) {
|
|
554
|
+
return { start: a, end: b };
|
|
555
|
+
}
|
|
556
|
+
return { start: b, end: a };
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const isCellSelected = (bufferRow: number, bufferCol: number): boolean => {
|
|
560
|
+
const sel = selectionNormalized();
|
|
561
|
+
if (!sel) return false;
|
|
562
|
+
const { start, end } = sel;
|
|
563
|
+
if (bufferRow < start.row || bufferRow > end.row) return false;
|
|
564
|
+
if (bufferRow === start.row && bufferRow === end.row) {
|
|
565
|
+
return bufferCol >= start.col && bufferCol <= end.col;
|
|
566
|
+
}
|
|
567
|
+
if (bufferRow === start.row) return bufferCol >= start.col;
|
|
568
|
+
if (bufferRow === end.row) return bufferCol <= end.col;
|
|
569
|
+
return true;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const getSelectedText = (): string => {
|
|
573
|
+
const sel = selectionNormalized();
|
|
574
|
+
if (!sel) return "";
|
|
575
|
+
const buffer = terminal.buffer.active;
|
|
576
|
+
const lines: string[] = [];
|
|
577
|
+
for (let row = sel.start.row; row <= sel.end.row; row++) {
|
|
578
|
+
const line = buffer.getLine(row);
|
|
579
|
+
if (!line) { lines.push(""); continue; }
|
|
580
|
+
// Slice by buffer columns via translateToString's range args, not by JS
|
|
581
|
+
// string index — wide chars (CJK/emoji) span 2 columns but 1 code unit, so
|
|
582
|
+
// a column index into the collapsed string copies the wrong span.
|
|
583
|
+
if (row === sel.start.row && row === sel.end.row) {
|
|
584
|
+
lines.push(line.translateToString(true, sel.start.col, sel.end.col + 1));
|
|
585
|
+
} else if (row === sel.start.row) {
|
|
586
|
+
lines.push(line.translateToString(true, sel.start.col));
|
|
587
|
+
} else if (row === sel.end.row) {
|
|
588
|
+
lines.push(line.translateToString(true, 0, sel.end.col + 1));
|
|
589
|
+
} else {
|
|
590
|
+
lines.push(line.translateToString(true));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return lines.map(l => l.trimEnd()).join("\n");
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const copySelectionToClipboard = () => {
|
|
597
|
+
const text = getSelectedText();
|
|
598
|
+
if (!text) return;
|
|
599
|
+
// OSC 52: set clipboard. Base64-encode the text.
|
|
600
|
+
const encoded = Buffer.from(text, "utf8").toString("base64");
|
|
601
|
+
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
|
|
602
|
+
// Hedged wording: OSC 52 is fire-and-forget, the terminal may not support it.
|
|
603
|
+
pushNotice(`Sent ${text.split("\n").length} line(s) to clipboard (OSC 52)`, "ok");
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const clearSelection = () => {
|
|
607
|
+
if (!selectionAnchor && !selectionEnd) return;
|
|
608
|
+
selectionAnchor = null;
|
|
609
|
+
selectionEnd = null;
|
|
610
|
+
isSelecting = false;
|
|
611
|
+
stopSelectionScroll();
|
|
612
|
+
scheduleRender();
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const stopSelectionScroll = () => {
|
|
616
|
+
if (selectionScrollTimer) {
|
|
617
|
+
clearInterval(selectionScrollTimer);
|
|
618
|
+
selectionScrollTimer = null;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const handleMouseEvent = (button: number, x: number, y: number, kind: string): string => {
|
|
623
|
+
const isPress = kind === "M";
|
|
624
|
+
const isRelease = kind === "m";
|
|
625
|
+
const btnId = button & 3; // 0=left, 1=middle, 2=right
|
|
626
|
+
const isMotion = (button & 32) !== 0;
|
|
627
|
+
const isWheel = (button & 64) !== 0;
|
|
628
|
+
|
|
629
|
+
// Wheel events: handle scrolling (pass through to existing logic)
|
|
630
|
+
if (isWheel) return "wheel";
|
|
631
|
+
|
|
632
|
+
// If remote mouse modes are active, don't handle selection locally
|
|
633
|
+
if (remoteMouseModes.size > 0) return "passthrough";
|
|
634
|
+
|
|
635
|
+
// Convert 1-based terminal coords to 0-based buffer coords
|
|
636
|
+
const screenRow = y - 1; // 0-based viewport row
|
|
637
|
+
const screenCol = x - 1; // 0-based viewport col
|
|
638
|
+
const bufferRow = viewY + screenRow;
|
|
639
|
+
const bufferCol = viewX + screenCol;
|
|
640
|
+
|
|
641
|
+
// Left button press: start selection
|
|
642
|
+
if (btnId === 0 && isPress && !isMotion) {
|
|
643
|
+
clearSelection();
|
|
644
|
+
selectionAnchor = { row: bufferRow, col: bufferCol };
|
|
645
|
+
selectionEnd = { row: bufferRow, col: bufferCol };
|
|
646
|
+
isSelecting = true;
|
|
647
|
+
return "handled";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Motion with left button held: extend selection
|
|
651
|
+
if (isMotion && isPress && isSelecting) {
|
|
652
|
+
selectionEnd = { row: bufferRow, col: bufferCol };
|
|
653
|
+
|
|
654
|
+
// Auto-scroll when dragging near viewport edges
|
|
655
|
+
stopSelectionScroll();
|
|
656
|
+
if (screenRow <= 0) {
|
|
657
|
+
// Dragging above viewport — scroll up
|
|
658
|
+
selectionScrollTimer = setInterval(() => {
|
|
659
|
+
if (!isSelecting) { stopSelectionScroll(); return; }
|
|
660
|
+
viewY = Math.max(0, viewY - SELECTION_SCROLL_SPEED);
|
|
661
|
+
if (selectionEnd) selectionEnd = { row: selectionEnd.row - SELECTION_SCROLL_SPEED, col: selectionEnd.col };
|
|
662
|
+
updateFollowOutputFromViewport();
|
|
663
|
+
scheduleRender();
|
|
664
|
+
}, SELECTION_SCROLL_INTERVAL);
|
|
665
|
+
} else if (screenRow >= localMetrics.viewportRows - 1) {
|
|
666
|
+
// Dragging below viewport — scroll down
|
|
667
|
+
selectionScrollTimer = setInterval(() => {
|
|
668
|
+
if (!isSelecting) { stopSelectionScroll(); return; }
|
|
669
|
+
const maxView = terminal.buffer.active.length - localMetrics.viewportRows;
|
|
670
|
+
viewY = Math.min(Math.max(0, maxView), viewY + SELECTION_SCROLL_SPEED);
|
|
671
|
+
if (selectionEnd) selectionEnd = { row: selectionEnd.row + SELECTION_SCROLL_SPEED, col: selectionEnd.col };
|
|
672
|
+
updateFollowOutputFromViewport();
|
|
673
|
+
scheduleRender();
|
|
674
|
+
}, SELECTION_SCROLL_INTERVAL);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
scheduleRender();
|
|
678
|
+
return "handled";
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Left button release: finalize selection
|
|
682
|
+
if (btnId === 0 && isRelease) {
|
|
683
|
+
stopSelectionScroll();
|
|
684
|
+
isSelecting = false;
|
|
685
|
+
const sel = selectionNormalized();
|
|
686
|
+
if (sel && (sel.start.row !== sel.end.row || sel.start.col !== sel.end.col)) {
|
|
687
|
+
copySelectionToClipboard();
|
|
688
|
+
} else {
|
|
689
|
+
// Click without drag — clear selection
|
|
690
|
+
clearSelection();
|
|
691
|
+
}
|
|
692
|
+
return "handled";
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return "unhandled";
|
|
696
|
+
};
|
|
697
|
+
|
|
340
698
|
const parseOscColor = (value: string): RgbColor | null => {
|
|
341
699
|
const trimmed = value.trim().toLowerCase();
|
|
342
700
|
if (!trimmed || trimmed === "default") {
|
|
@@ -423,8 +781,46 @@ const clampView = () => {
|
|
|
423
781
|
|
|
424
782
|
const isViewportAtBottom = () => viewY >= getMaxViewY();
|
|
425
783
|
|
|
784
|
+
// Anchor the scrolled viewport to a buffer line marker. viewY is an absolute
|
|
785
|
+
// buffer index, so once scrollback is full every trimmed line drags a fixed
|
|
786
|
+
// viewY toward the bottom while output streams. The marker tracks the line
|
|
787
|
+
// across trims/reflows so the view stays put.
|
|
788
|
+
type ScrollAnchor = NonNullable<ReturnType<typeof terminal.registerMarker>>;
|
|
789
|
+
let scrollAnchor: ScrollAnchor | null = null;
|
|
790
|
+
|
|
791
|
+
const releaseScrollAnchor = () => {
|
|
792
|
+
if (scrollAnchor) {
|
|
793
|
+
if (!scrollAnchor.isDisposed) scrollAnchor.dispose();
|
|
794
|
+
scrollAnchor = null;
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
const updateScrollAnchor = () => {
|
|
799
|
+
releaseScrollAnchor();
|
|
800
|
+
if (followOutput) return;
|
|
801
|
+
const buffer = terminal.buffer.active;
|
|
802
|
+
const cursorAbs = buffer.baseY + buffer.cursorY;
|
|
803
|
+
scrollAnchor = terminal.registerMarker(viewY - cursorAbs) ?? null;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const restoreViewFromAnchor = () => {
|
|
807
|
+
if (!followOutput && scrollAnchor) {
|
|
808
|
+
if (scrollAnchor.line >= 0) {
|
|
809
|
+
viewY = scrollAnchor.line;
|
|
810
|
+
} else {
|
|
811
|
+
// The anchored line was trimmed out of scrollback — hold at the top.
|
|
812
|
+
viewY = 0;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
clampView();
|
|
816
|
+
if (!followOutput && (!scrollAnchor || scrollAnchor.isDisposed)) {
|
|
817
|
+
updateScrollAnchor();
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
426
821
|
const updateFollowOutputFromViewport = () => {
|
|
427
822
|
followOutput = isViewportAtBottom();
|
|
823
|
+
updateScrollAnchor();
|
|
428
824
|
};
|
|
429
825
|
|
|
430
826
|
const followCursorWithMargin = () => {
|
|
@@ -462,15 +858,131 @@ const ensureCursorVisible = (_force: boolean) => {
|
|
|
462
858
|
viewX = nextX;
|
|
463
859
|
viewY = clamp(nextY, 0, maxY);
|
|
464
860
|
followOutput = true;
|
|
861
|
+
releaseScrollAnchor();
|
|
465
862
|
};
|
|
466
863
|
|
|
864
|
+
// Coalesce a burst of output chunks into one repaint per frame (~60fps) instead
|
|
865
|
+
// of one per chunk. Imperceptible locally; meaningfully less work and wire
|
|
866
|
+
// traffic over a slow remote link.
|
|
867
|
+
const RENDER_THROTTLE_MS = 16;
|
|
467
868
|
const scheduleRender = () => {
|
|
468
869
|
if (renderScheduled) return;
|
|
469
870
|
renderScheduled = true;
|
|
470
871
|
setTimeout(() => {
|
|
471
872
|
renderScheduled = false;
|
|
472
873
|
render();
|
|
473
|
-
},
|
|
874
|
+
}, RENDER_THROTTLE_MS);
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// ── Scrollback search ──
|
|
878
|
+
|
|
879
|
+
// "active" = the current match (bright), "match" = any other match (dim).
|
|
880
|
+
const getSearchMatchKind = (row: number, col: number): "active" | "match" | null => {
|
|
881
|
+
if (searchMatches.length === 0) return null;
|
|
882
|
+
const startCols = searchMatchesByRow.get(row);
|
|
883
|
+
if (!startCols) return null;
|
|
884
|
+
for (const start of startCols) {
|
|
885
|
+
if (col >= start && col < start + searchQuery.length) {
|
|
886
|
+
const active = searchMatches[searchIndex];
|
|
887
|
+
return active && active.row === row && active.col === start ? "active" : "match";
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
const computeSearchMatches = () => {
|
|
894
|
+
searchMatches = [];
|
|
895
|
+
searchMatchesByRow = new Map();
|
|
896
|
+
searchIndex = -1;
|
|
897
|
+
if (!searchQuery) return;
|
|
898
|
+
const buffer = terminal.buffer.active;
|
|
899
|
+
const needle = searchQuery.toLowerCase();
|
|
900
|
+
for (let row = 0; row < buffer.length && searchMatches.length < SEARCH_MAX_MATCHES; row++) {
|
|
901
|
+
const line = buffer.getLine(row);
|
|
902
|
+
if (!line) continue;
|
|
903
|
+
const text = line.translateToString(true).toLowerCase();
|
|
904
|
+
let from = 0;
|
|
905
|
+
while (searchMatches.length < SEARCH_MAX_MATCHES) {
|
|
906
|
+
const idx = text.indexOf(needle, from);
|
|
907
|
+
if (idx === -1) break;
|
|
908
|
+
searchMatches.push({ row, col: idx });
|
|
909
|
+
const rowCols = searchMatchesByRow.get(row);
|
|
910
|
+
if (rowCols) rowCols.push(idx);
|
|
911
|
+
else searchMatchesByRow.set(row, [idx]);
|
|
912
|
+
from = idx + needle.length;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const jumpToMatch = (index: number) => {
|
|
918
|
+
if (searchMatches.length === 0) { searchIndex = -1; return; }
|
|
919
|
+
searchIndex = ((index % searchMatches.length) + searchMatches.length) % searchMatches.length;
|
|
920
|
+
const m = searchMatches[searchIndex];
|
|
921
|
+
followOutput = false;
|
|
922
|
+
releaseScrollAnchor();
|
|
923
|
+
viewY = m.row - Math.floor(localMetrics.viewportRows / 2);
|
|
924
|
+
clampView();
|
|
925
|
+
scheduleRender();
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const enterSearch = () => {
|
|
929
|
+
searchMode = true;
|
|
930
|
+
searchQuery = "";
|
|
931
|
+
searchMatches = [];
|
|
932
|
+
searchMatchesByRow = new Map();
|
|
933
|
+
searchIndex = -1;
|
|
934
|
+
scheduleRender();
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const exitSearch = () => {
|
|
938
|
+
searchMode = false;
|
|
939
|
+
searchMatches = [];
|
|
940
|
+
searchMatchesByRow = new Map();
|
|
941
|
+
searchIndex = -1;
|
|
942
|
+
// Keep the scrollback position (less/tmux convention) but anchor it so output
|
|
943
|
+
// doesn't drag it, and point the way back to the live view.
|
|
944
|
+
if (!followOutput) {
|
|
945
|
+
updateScrollAnchor();
|
|
946
|
+
pushNotice(`${keyLabelShort}+0 to return to live output`);
|
|
947
|
+
}
|
|
948
|
+
scheduleRender();
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
// Handle a stdin chunk while in search mode. Consumes the input (never forwarded
|
|
952
|
+
// to the remote): Esc exits, Enter/↓ jumps to the next match, ↑ to the previous,
|
|
953
|
+
// Backspace edits, and printable characters extend the query (which live-jumps
|
|
954
|
+
// to the first match).
|
|
955
|
+
const handleSearchInput = (input: string) => {
|
|
956
|
+
if (input === "\x1b") { exitSearch(); return; }
|
|
957
|
+
if (input === "\x1b[A" || input === "\x1bOA") {
|
|
958
|
+
if (searchMatches.length > 0) jumpToMatch(searchIndex - 1);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (input === "\x1b[B" || input === "\x1bOB") {
|
|
962
|
+
if (searchMatches.length > 0) jumpToMatch(searchIndex + 1);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
// Ignore other escape sequences (alt-combos etc.) so their bytes don't pollute the query.
|
|
966
|
+
if (input.startsWith("\x1b") && input.length > 1) { return; }
|
|
967
|
+
if (input === "\r" || input === "\n") {
|
|
968
|
+
if (searchMatches.length > 0) jumpToMatch(searchIndex + 1);
|
|
969
|
+
else scheduleRender();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (input === "\x7f" || input === "\b") {
|
|
973
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
974
|
+
computeSearchMatches();
|
|
975
|
+
if (searchMatches.length > 0) jumpToMatch(0);
|
|
976
|
+
else scheduleRender();
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
// Append only printable characters; ignore control/escape sequences (arrows etc.).
|
|
980
|
+
const printable = Array.from(input).filter((ch) => ch >= " " && ch !== "\x7f").join("");
|
|
981
|
+
if (!printable) { scheduleRender(); return; }
|
|
982
|
+
searchQuery += printable;
|
|
983
|
+
computeSearchMatches();
|
|
984
|
+
if (searchMatches.length > 0) jumpToMatch(0);
|
|
985
|
+
else scheduleRender();
|
|
474
986
|
};
|
|
475
987
|
|
|
476
988
|
const setCursorVisible = (visible: boolean) => {
|
|
@@ -540,8 +1052,8 @@ terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
|
|
|
540
1052
|
return false;
|
|
541
1053
|
});
|
|
542
1054
|
|
|
543
|
-
const pushNotice = (message: string) => {
|
|
544
|
-
notice = { message, expiresAt: Date.now() + NOTICE_MS };
|
|
1055
|
+
const pushNotice = (message: string, kind: NoticeKind = "info") => {
|
|
1056
|
+
notice = { message, kind, expiresAt: Date.now() + NOTICE_MS };
|
|
545
1057
|
if (noticeTimer) {
|
|
546
1058
|
clearTimeout(noticeTimer);
|
|
547
1059
|
}
|
|
@@ -560,6 +1072,8 @@ const sendMessage = (message: ClientMessage) => {
|
|
|
560
1072
|
|
|
561
1073
|
const sendOscResponse = (id: number, color: RgbColor | null) => {
|
|
562
1074
|
if (!color) return;
|
|
1075
|
+
// Don't answer color queries that surface while replaying snapshot history.
|
|
1076
|
+
if (replayingSnapshot) return;
|
|
563
1077
|
const toHex4 = (value: number) => Math.round(value * 257).toString(16).padStart(4, "0");
|
|
564
1078
|
const payload = `\x1b]${id};rgb:${toHex4(color.r)}/${toHex4(color.g)}/${toHex4(color.b)}\x1b\\`;
|
|
565
1079
|
sendMessage({ type: "input", data: payload });
|
|
@@ -612,7 +1126,7 @@ const getUrl = () => {
|
|
|
612
1126
|
const separator = config.server.includes("?") ? "&" : "?";
|
|
613
1127
|
return `${config.server}${separator}room=${encodeURIComponent(config.room)}&name=${encodeURIComponent(
|
|
614
1128
|
config.name
|
|
615
|
-
)}&cols=${localMetrics.viewportCols}&rows=${localMetrics.viewportRows}`;
|
|
1129
|
+
)}&cols=${Math.max(2, localMetrics.viewportCols)}&rows=${Math.max(2, localMetrics.viewportRows)}`;
|
|
616
1130
|
};
|
|
617
1131
|
|
|
618
1132
|
const isPidAlive = (pid: number) => {
|
|
@@ -703,7 +1217,8 @@ const applyRemoteSize = (cols: number, rows: number) => {
|
|
|
703
1217
|
if (followOutput) {
|
|
704
1218
|
followCursorWithMargin();
|
|
705
1219
|
} else {
|
|
706
|
-
|
|
1220
|
+
// Markers survive reflow, so the anchor re-locates the viewed content.
|
|
1221
|
+
restoreViewFromAnchor();
|
|
707
1222
|
}
|
|
708
1223
|
scheduleRender();
|
|
709
1224
|
};
|
|
@@ -716,18 +1231,21 @@ const applySyncSize = () => {
|
|
|
716
1231
|
const needsResize = cols !== remoteCols || rows !== remoteRows;
|
|
717
1232
|
applyRemoteSize(cols, rows);
|
|
718
1233
|
if (needsResize) {
|
|
719
|
-
|
|
1234
|
+
// Protocol rejects resize below 2x2 — clamp so tiny terminals don't send
|
|
1235
|
+
// messages the server refuses.
|
|
1236
|
+
sendMessage({ type: "resize", cols: Math.max(2, cols), rows: Math.max(2, rows) });
|
|
720
1237
|
}
|
|
721
1238
|
};
|
|
722
1239
|
|
|
723
1240
|
const enableMouseCapture = () => {
|
|
724
1241
|
if (!process.stdout.isTTY) return;
|
|
725
|
-
|
|
1242
|
+
// 1000=normal tracking, 1002=button-event tracking (motion while pressed), 1006=SGR encoding
|
|
1243
|
+
process.stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
|
|
726
1244
|
};
|
|
727
1245
|
|
|
728
1246
|
const disableMouseCapture = () => {
|
|
729
1247
|
if (!process.stdout.isTTY) return;
|
|
730
|
-
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
1248
|
+
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
|
|
731
1249
|
};
|
|
732
1250
|
|
|
733
1251
|
const setMouseCapture = (enabled: boolean) => {
|
|
@@ -749,6 +1267,8 @@ const initUi = () => {
|
|
|
749
1267
|
process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
|
|
750
1268
|
if (mouseCapture) {
|
|
751
1269
|
enableMouseCapture();
|
|
1270
|
+
} else {
|
|
1271
|
+
disableMouseCapture();
|
|
752
1272
|
}
|
|
753
1273
|
lastRenderCols = 0;
|
|
754
1274
|
lastRenderRows = 0;
|
|
@@ -763,25 +1283,77 @@ const restoreUi = () => {
|
|
|
763
1283
|
|
|
764
1284
|
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
|
|
765
1285
|
|
|
766
|
-
|
|
1286
|
+
// Display width of a single code point in terminal cells. Wide (CJK/emoji) = 2,
|
|
1287
|
+
// combining marks / zero-width / control = 0, everything else = 1. Without this
|
|
1288
|
+
// the status bar measures user data (session name, cwd) in UTF-16 code units, so
|
|
1289
|
+
// a CJK path over-pads past the terminal width and wraps/garbles the bottom row.
|
|
1290
|
+
const isWideCodePoint = (cp: number): boolean =>
|
|
1291
|
+
cp >= 0x1100 && (
|
|
1292
|
+
cp <= 0x115f || // Hangul Jamo
|
|
1293
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
1294
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals .. Kangxi
|
|
1295
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana .. CJK compat symbols
|
|
1296
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
|
|
1297
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
|
|
1298
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
1299
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
1300
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
1301
|
+
(cp >= 0xfe10 && cp <= 0xfe19) || // Vertical forms
|
|
1302
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility forms
|
|
1303
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
|
1304
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
1305
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // emoji & symbols
|
|
1306
|
+
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B and beyond
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
const isZeroWidthCodePoint = (cp: number): boolean =>
|
|
1310
|
+
cp === 0x200d || // zero-width joiner
|
|
1311
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
|
|
1312
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
1313
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
1314
|
+
(cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
|
|
1315
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
1316
|
+
(cp >= 0xfe20 && cp <= 0xfe2f); // combining half marks
|
|
1317
|
+
|
|
1318
|
+
const charDisplayWidth = (cp: number): number => {
|
|
1319
|
+
if (cp === 0) return 0;
|
|
1320
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0; // C0/C1 control
|
|
1321
|
+
if (isZeroWidthCodePoint(cp)) return 0;
|
|
1322
|
+
return isWideCodePoint(cp) ? 2 : 1;
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
const stringWidth = (text: string): number => {
|
|
1326
|
+
let width = 0;
|
|
1327
|
+
for (const ch of text) {
|
|
1328
|
+
width += charDisplayWidth(ch.codePointAt(0) ?? 0);
|
|
1329
|
+
}
|
|
1330
|
+
return width;
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
const visibleLength = (text: string) => stringWidth(text.replace(ANSI_REGEX, ""));
|
|
767
1334
|
|
|
768
1335
|
const truncateAnsi = (text: string, cols: number) => {
|
|
769
1336
|
if (cols <= 0) return "";
|
|
770
1337
|
let out = "";
|
|
771
|
-
let
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
if (
|
|
1338
|
+
let width = 0;
|
|
1339
|
+
let i = 0;
|
|
1340
|
+
while (i < text.length) {
|
|
1341
|
+
if (text[i] === "\x1b" && text[i + 1] === "[") {
|
|
775
1342
|
const end = text.indexOf("m", i + 2);
|
|
776
1343
|
if (end === -1) {
|
|
777
1344
|
break;
|
|
778
1345
|
}
|
|
779
1346
|
out += text.slice(i, end + 1);
|
|
780
|
-
i = end;
|
|
1347
|
+
i = end + 1;
|
|
781
1348
|
continue;
|
|
782
1349
|
}
|
|
783
|
-
|
|
784
|
-
|
|
1350
|
+
const cp = text.codePointAt(i) ?? 0;
|
|
1351
|
+
const ch = String.fromCodePoint(cp);
|
|
1352
|
+
const cw = charDisplayWidth(cp);
|
|
1353
|
+
if (width + cw > cols) break;
|
|
1354
|
+
out += ch;
|
|
1355
|
+
width += cw;
|
|
1356
|
+
i += ch.length; // advance past surrogate pairs
|
|
785
1357
|
}
|
|
786
1358
|
return out;
|
|
787
1359
|
};
|
|
@@ -799,7 +1371,7 @@ type BarSegment = {
|
|
|
799
1371
|
dropPriority?: number;
|
|
800
1372
|
};
|
|
801
1373
|
|
|
802
|
-
const joinBarSegments = (segments: BarSegment[]) => segments.map((segment) => segment.text).join("
|
|
1374
|
+
const joinBarSegments = (segments: BarSegment[]) => segments.map((segment) => segment.text).join(" · ");
|
|
803
1375
|
|
|
804
1376
|
const fitBarSegments = (left: string, segments: BarSegment[], cols: number) => {
|
|
805
1377
|
const active = segments.filter((segment) => segment.text);
|
|
@@ -835,7 +1407,7 @@ const composeBar = (left: string, right: string, cols: number) => {
|
|
|
835
1407
|
}
|
|
836
1408
|
const rightLen = visibleLength(right);
|
|
837
1409
|
if (rightLen >= cols) {
|
|
838
|
-
return padOrTrim(right
|
|
1410
|
+
return padOrTrim(truncateAnsi(right, cols), cols);
|
|
839
1411
|
}
|
|
840
1412
|
const availableLeft = Math.max(0, cols - rightLen - 1);
|
|
841
1413
|
const trimmedLeft = visibleLength(left) > availableLeft ? truncateAnsi(left, availableLeft) : left;
|
|
@@ -853,38 +1425,32 @@ const formatCwd = (cwdPath: string | null) => {
|
|
|
853
1425
|
return cwdPath;
|
|
854
1426
|
};
|
|
855
1427
|
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
const BAR_FG = "\x1b[38;5;234m";
|
|
859
|
-
const BAR_ACCENT = "\x1b[38;5;93m";
|
|
860
|
-
const BAR_DIM = "\x1b[38;5;245m";
|
|
861
|
-
const BAR_BOLD = "\x1b[1m";
|
|
862
|
-
const BAR_RESET_FG = "\x1b[22m\x1b[38;5;234m";
|
|
1428
|
+
const renderBar = (text: string, variant: "status" | "hint") =>
|
|
1429
|
+
`${variant === "status" ? BAR.statusBg : BAR.hintBg}${BAR.fg}${text}\x1b[0m`;
|
|
863
1430
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const decorateTop = (text: string, secondaryTokens: string[],
|
|
1431
|
+
// Status line decoration: semantic state dot, session chip, dim separators and
|
|
1432
|
+
// secondary tokens. Token replacement is best-effort — if composeBar truncated
|
|
1433
|
+
// a token away, the replace simply doesn't match.
|
|
1434
|
+
const decorateTop = (text: string, secondaryTokens: string[], chipToken: string, dotAnsi: string) => {
|
|
868
1435
|
let output = text;
|
|
869
|
-
output = output.replace("●", `${
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (!token) continue;
|
|
873
|
-
output = output.replace(token, `${BAR_BOLD}${token}${BAR_RESET_FG}`);
|
|
1436
|
+
output = output.replace("●", `${dotAnsi}●${BAR.resetFg}`);
|
|
1437
|
+
if (chipToken) {
|
|
1438
|
+
output = output.replace(chipToken, `${BAR.chip}${chipToken}\x1b[22m${BAR.statusBg}${BAR.fg}`);
|
|
874
1439
|
}
|
|
1440
|
+
output = output.replace(/ · /g, ` ${BAR.dim}·${BAR.resetFg} `);
|
|
875
1441
|
for (const token of secondaryTokens) {
|
|
876
1442
|
if (!token) continue;
|
|
877
|
-
output = output.replace(token, `${
|
|
1443
|
+
output = output.replace(token, `${BAR.dim}${token}${BAR.resetFg}`);
|
|
878
1444
|
}
|
|
879
1445
|
return output;
|
|
880
1446
|
};
|
|
881
1447
|
|
|
882
1448
|
const decorateBottom = (text: string, secondaryTokens: string[]) => {
|
|
883
1449
|
let output = text;
|
|
884
|
-
output = output.replace(/
|
|
1450
|
+
output = output.replace(/ · /g, ` ${BAR.dim}·${BAR.resetFg} `);
|
|
885
1451
|
for (const token of secondaryTokens) {
|
|
886
1452
|
if (!token) continue;
|
|
887
|
-
output = output.replace(token, `${
|
|
1453
|
+
output = output.replace(token, `${BAR.dim}${token}${BAR.resetFg}`);
|
|
888
1454
|
}
|
|
889
1455
|
return output;
|
|
890
1456
|
};
|
|
@@ -1023,13 +1589,20 @@ const renderLine = (lineIndex: number, cursorRow: number, cursorCol: number) =>
|
|
|
1023
1589
|
continue;
|
|
1024
1590
|
}
|
|
1025
1591
|
const cellData = line.getCell(remoteCol, cell);
|
|
1592
|
+
const bufferRow = base + lineIndex;
|
|
1026
1593
|
const isCursorCell = isCursorLine && remoteCol === cursorCol;
|
|
1594
|
+
const isSelected = isCellSelected(bufferRow, remoteCol);
|
|
1027
1595
|
if (!cellData) {
|
|
1028
|
-
if (
|
|
1029
|
-
output += "\x1b[0m";
|
|
1596
|
+
if (isSelected) {
|
|
1597
|
+
output += "\x1b[7m \x1b[0m";
|
|
1030
1598
|
currentKey = "default";
|
|
1599
|
+
} else {
|
|
1600
|
+
if (currentKey !== "default") {
|
|
1601
|
+
output += "\x1b[0m";
|
|
1602
|
+
currentKey = "default";
|
|
1603
|
+
}
|
|
1604
|
+
output += " ";
|
|
1031
1605
|
}
|
|
1032
|
-
output += " ";
|
|
1033
1606
|
continue;
|
|
1034
1607
|
}
|
|
1035
1608
|
|
|
@@ -1039,7 +1612,7 @@ const renderLine = (lineIndex: number, cursorRow: number, cursorCol: number) =>
|
|
|
1039
1612
|
chars = " ";
|
|
1040
1613
|
}
|
|
1041
1614
|
|
|
1042
|
-
if (width === 0 && !isCursorCell) {
|
|
1615
|
+
if (width === 0 && !isCursorCell && !isSelected) {
|
|
1043
1616
|
if (currentKey !== "default") {
|
|
1044
1617
|
output += "\x1b[0m";
|
|
1045
1618
|
currentKey = "default";
|
|
@@ -1047,7 +1620,7 @@ const renderLine = (lineIndex: number, cursorRow: number, cursorCol: number) =>
|
|
|
1047
1620
|
output += " ";
|
|
1048
1621
|
continue;
|
|
1049
1622
|
}
|
|
1050
|
-
if (width === 0 && isCursorCell) {
|
|
1623
|
+
if (width === 0 && (isCursorCell || isSelected)) {
|
|
1051
1624
|
width = 1;
|
|
1052
1625
|
chars = " ";
|
|
1053
1626
|
}
|
|
@@ -1061,10 +1634,17 @@ const renderLine = (lineIndex: number, cursorRow: number, cursorCol: number) =>
|
|
|
1061
1634
|
continue;
|
|
1062
1635
|
}
|
|
1063
1636
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1637
|
+
// Search hits: the current match gets a bright yellow highlight, all other
|
|
1638
|
+
// matches a dim grey one (cursor still wins so it stays visible on a match).
|
|
1639
|
+
const searchKind = !isCursorCell ? getSearchMatchKind(bufferRow, remoteCol) : null;
|
|
1640
|
+
const style = searchKind
|
|
1641
|
+
? (searchKind === "active"
|
|
1642
|
+
? { key: "search-active", ansi: "\x1b[0m\x1b[43m\x1b[30m" }
|
|
1643
|
+
: { key: "search-match", ansi: "\x1b[0m\x1b[48;5;240m\x1b[37m" })
|
|
1644
|
+
: styleKeyForCell(
|
|
1645
|
+
cellData as ReturnType<typeof terminal.buffer.active.getNullCell>,
|
|
1646
|
+
isCursorCell || isSelected
|
|
1647
|
+
);
|
|
1068
1648
|
if (style.key !== currentKey) {
|
|
1069
1649
|
output += style.ansi;
|
|
1070
1650
|
currentKey = style.key;
|
|
@@ -1090,30 +1670,63 @@ const render = () => {
|
|
|
1090
1670
|
localMetrics = getLocalMetrics();
|
|
1091
1671
|
clampView();
|
|
1092
1672
|
|
|
1673
|
+
let forceFull = false;
|
|
1093
1674
|
if (localMetrics.cols !== lastRenderCols || localMetrics.rows !== lastRenderRows) {
|
|
1094
1675
|
process.stdout.write("\x1b[2J");
|
|
1095
1676
|
lastRenderCols = localMetrics.cols;
|
|
1096
1677
|
lastRenderRows = localMetrics.rows;
|
|
1678
|
+
lastRenderedLines = []; // dimensions changed — repaint everything
|
|
1679
|
+
forceFull = true;
|
|
1097
1680
|
}
|
|
1098
1681
|
|
|
1099
|
-
const
|
|
1682
|
+
const others = presence.filter((client) => client.id !== clientId);
|
|
1683
|
+
const controllerName = controllerId
|
|
1684
|
+
? (controllerId === clientId ? "you" : (presence.find((c) => c.id === controllerId)?.name || "peer"))
|
|
1685
|
+
: null;
|
|
1100
1686
|
const statusLabel = status === "connected" ? "" : status;
|
|
1101
1687
|
const shareUrl = buildShareUrl();
|
|
1102
1688
|
const hopDaemonLabel = buildHopDaemonLabel();
|
|
1103
1689
|
|
|
1104
1690
|
const cwdLabel = formatCwd(liveCwd);
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1691
|
+
// Session name renders as an accent chip at the left edge; the semantic state
|
|
1692
|
+
// dot sits alone in the right corner (clear of the solid chip): green
|
|
1693
|
+
// connected · amber connecting/reconnecting · red disconnected · accent
|
|
1694
|
+
// (purple) when control is locked.
|
|
1695
|
+
const dotAnsi =
|
|
1696
|
+
status === "connected"
|
|
1697
|
+
? (!collabMode ? BAR.accent : BAR.ok)
|
|
1698
|
+
: status === "disconnected"
|
|
1699
|
+
? BAR.err
|
|
1700
|
+
: BAR.warn;
|
|
1701
|
+
const chipToken = ` ${sessionLabel} `;
|
|
1702
|
+
// Chip is flush with the left edge, tab-style (its padding is inside the pill).
|
|
1703
|
+
const bottomLeft = `${chipToken}${cwdLabel ? ` ${cwdLabel}` : ""}`;
|
|
1704
|
+
// Only surface the non-default state (GUI convention): autofit is on by default.
|
|
1705
|
+
const autofitLabel = syncSize ? "" : "manual";
|
|
1706
|
+
// Viewport detached from live output: show where we are in the scrollback and
|
|
1707
|
+
// how to get back. No dropPriority — it must survive narrow widths.
|
|
1708
|
+
const bufferLineCount = terminal.buffer.active.length;
|
|
1709
|
+
const scrollLabel = !followOutput
|
|
1710
|
+
? `scroll ${Math.min(viewY + localMetrics.viewportRows, bufferLineCount)}/${bufferLineCount} · ${keyLabelShort}+0 live`
|
|
1711
|
+
: "";
|
|
1712
|
+
// Show peer names (with a * typing marker) instead of just a count, and a
|
|
1713
|
+
// control-lock indicator when collaborative typing is off — parity with web.
|
|
1714
|
+
const peerNames = others.map((c) => (c.typing ? `${c.name}*` : c.name)).join(", ");
|
|
1715
|
+
const peerLabel = others.length ? `peers: ${peerNames}` : "";
|
|
1716
|
+
const controlLabel = (!collabMode && controllerName) ? `locked: ${controllerName}` : "";
|
|
1717
|
+
const candidateSegments: Array<BarSegment | null> = [
|
|
1718
|
+
scrollLabel ? { text: scrollLabel } : null,
|
|
1719
|
+
statusLabel ? { text: statusLabel } : null,
|
|
1720
|
+
hopDaemonLabel ? { text: hopDaemonLabel, secondary: true, dropPriority: 1 } : null,
|
|
1721
|
+
!statusLabel && shareUrl ? { text: shareUrl, secondary: true, dropPriority: 0 } : null,
|
|
1722
|
+
autofitLabel ? { text: autofitLabel, secondary: true, dropPriority: 3 } : null,
|
|
1723
|
+
peerLabel ? { text: peerLabel, secondary: true, dropPriority: 4 } : null,
|
|
1724
|
+
controlLabel ? { text: controlLabel, secondary: true, dropPriority: 6 } : null,
|
|
1725
|
+
{ text: "● " } // state dot, rightmost; no dropPriority — always visible
|
|
1726
|
+
];
|
|
1108
1727
|
const rightSegments = fitBarSegments(
|
|
1109
1728
|
bottomLeft,
|
|
1110
|
-
|
|
1111
|
-
statusLabel ? { text: statusLabel } : null,
|
|
1112
|
-
hopDaemonLabel ? { text: hopDaemonLabel, secondary: true, dropPriority: 1 } : null,
|
|
1113
|
-
!statusLabel && shareUrl ? { text: shareUrl, secondary: true, dropPriority: 0 } : null,
|
|
1114
|
-
{ text: autofitLabel, secondary: true, dropPriority: 2 },
|
|
1115
|
-
{ text: peerLabel, secondary: true, dropPriority: 3 }
|
|
1116
|
-
].filter((segment): segment is BarSegment => !!segment),
|
|
1729
|
+
candidateSegments.filter((segment): segment is BarSegment => !!segment),
|
|
1117
1730
|
localMetrics.cols
|
|
1118
1731
|
);
|
|
1119
1732
|
const rightPriority = rightSegments
|
|
@@ -1125,17 +1738,59 @@ const render = () => {
|
|
|
1125
1738
|
);
|
|
1126
1739
|
const topPriority = rightSegments
|
|
1127
1740
|
.filter((segment) => segment.text === hopDaemonLabel || segment.text === shareUrl)
|
|
1128
|
-
.map((segment) => segment.text)
|
|
1741
|
+
.map((segment) => segment.text)
|
|
1742
|
+
.concat(cwdLabel ? [cwdLabel] : []); // cwd is secondary next to the chip
|
|
1129
1743
|
const bottomPrimary = renderBar(
|
|
1130
|
-
decorateTop(composeBar(bottomLeft, rightPrimary, localMetrics.cols), topPriority,
|
|
1131
|
-
"
|
|
1744
|
+
decorateTop(composeBar(bottomLeft, rightPrimary, localMetrics.cols), topPriority, chipToken, dotAnsi),
|
|
1745
|
+
"status"
|
|
1132
1746
|
);
|
|
1133
1747
|
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1748
|
+
// Notices render as toasts: ✓ confirmations, ! warnings, accent edge for info.
|
|
1749
|
+
const noticeText = notice && notice.expiresAt > Date.now()
|
|
1750
|
+
? (notice.kind === "ok"
|
|
1751
|
+
? ` ${BAR.ok}✓ ${BAR.resetFg}${notice.message} `
|
|
1752
|
+
: notice.kind === "warn"
|
|
1753
|
+
? ` ${BAR.warn}${BAR.bold}! ${BAR.resetFg}${notice.message} `
|
|
1754
|
+
: ` ${BAR.accent}▎${BAR.resetFg}${notice.message} `)
|
|
1755
|
+
: "";
|
|
1756
|
+
// Keycap-style hints: keys at full strength, labels dim. Exit hints
|
|
1757
|
+
// (detach/kill) lead so right-edge truncation never drops the way out.
|
|
1758
|
+
const kCtrl = (k: string) => (isMac ? `⌃${k}` : `Ctrl+${k}`);
|
|
1759
|
+
const kOpt = (k: string) => (isMac ? `⌥${k}` : `Alt+${k}`);
|
|
1760
|
+
const hintPairs: Array<[string, string]> = [
|
|
1761
|
+
[kCtrl("G"), "detach"],
|
|
1762
|
+
[kCtrl("Q"), "kill"],
|
|
1763
|
+
[kOpt("←→↑↓"), "pan"],
|
|
1764
|
+
[kOpt("0"), "live"],
|
|
1765
|
+
[kOpt("A"), "autofit"],
|
|
1766
|
+
[kOpt("B"), `status ${showStatusBar ? "on" : "off"}`],
|
|
1767
|
+
[kOpt("M"), `mouse ${mouseCapture ? "on" : "off"}`],
|
|
1768
|
+
[kOpt("F"), "find"],
|
|
1769
|
+
[kOpt("C"), "control"],
|
|
1770
|
+
[kOpt("T"), "hints"],
|
|
1771
|
+
[kOpt("\\"), "literal"]
|
|
1772
|
+
];
|
|
1773
|
+
const controls = ` ${hintPairs.map(([key, label]) => `${BAR.fg}${key} ${BAR.dim}${label}`).join(" · ")}`;
|
|
1774
|
+
const reconnecting = !connected && reconnectAttempt > 0;
|
|
1775
|
+
let hintInner: string;
|
|
1776
|
+
if (searchMode) {
|
|
1777
|
+
const count = searchQuery
|
|
1778
|
+
? (searchMatches.length > 0
|
|
1779
|
+
? `${searchIndex + 1}/${searchMatches.length}`
|
|
1780
|
+
: `${BAR.warn}no matches${BAR.dim}`)
|
|
1781
|
+
: "";
|
|
1782
|
+
hintInner = `${BAR.accent} /${searchQuery}${BAR.resetFg}${BAR.dim} ${count} · Enter/↓ next · ↑ prev · Esc close${BAR.resetFg}`;
|
|
1783
|
+
} else if (reconnecting) {
|
|
1784
|
+
const secsLeft = nextReconnectAt > Date.now() ? Math.ceil((nextReconnectAt - Date.now()) / 1000) : 0;
|
|
1785
|
+
const detail = secsLeft > 0 ? `retry in ${secsLeft}s` : "connecting…";
|
|
1786
|
+
const reason = lastWsError ? ` (${lastWsError})` : "";
|
|
1787
|
+
hintInner = `${BAR.reconnect} ⟳ Reconnecting${reason} — ${detail} · attempt ${reconnectAttempt} · ${kCtrl("G")} detach ${BAR.resetFg}`;
|
|
1788
|
+
} else {
|
|
1789
|
+
hintInner = noticeText || controls;
|
|
1790
|
+
}
|
|
1136
1791
|
const hintLine = renderBar(
|
|
1137
|
-
composeBar(
|
|
1138
|
-
"
|
|
1792
|
+
composeBar(hintInner, "", localMetrics.cols),
|
|
1793
|
+
"hint"
|
|
1139
1794
|
);
|
|
1140
1795
|
|
|
1141
1796
|
const buffer = terminal.buffer.active;
|
|
@@ -1155,16 +1810,49 @@ const render = () => {
|
|
|
1155
1810
|
lines.push(hintLine);
|
|
1156
1811
|
}
|
|
1157
1812
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1813
|
+
// Dirty-line diff: position to and rewrite only the rows that changed since
|
|
1814
|
+
// the last frame. Each rendered line already pads/clears to the row width
|
|
1815
|
+
// (renderLine ends with \x1b[K; bars are width-padded), so a positioned write
|
|
1816
|
+
// fully replaces the old content. Typing one character now repaints ~1-2 rows
|
|
1817
|
+
// instead of the whole screen.
|
|
1818
|
+
let output = "\x1b[?25l";
|
|
1819
|
+
let changed = 0;
|
|
1820
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1821
|
+
if (forceFull || lines[i] !== lastRenderedLines[i]) {
|
|
1822
|
+
output += `\x1b[${i + 1};1H` + lines[i];
|
|
1823
|
+
changed += 1;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
lastRenderedLines = lines;
|
|
1827
|
+
if (changed > 0) {
|
|
1828
|
+
process.stdout.write(output);
|
|
1829
|
+
}
|
|
1160
1830
|
};
|
|
1161
1831
|
|
|
1162
1832
|
const scheduleReconnect = () => {
|
|
1163
1833
|
if (!shouldReconnect) return;
|
|
1164
1834
|
|
|
1835
|
+
// Never managed to connect at all: retrying forever just hides the problem.
|
|
1836
|
+
if (!everConnected && reconnectAttempt >= MAX_NEVER_CONNECTED_ATTEMPTS) {
|
|
1837
|
+
shouldReconnect = false;
|
|
1838
|
+
exitMessage = `Could not connect to ${config.server} after ${reconnectAttempt + 1} attempts${
|
|
1839
|
+
lastWsError ? ` (${lastWsError})` : ""
|
|
1840
|
+
}. Is the server running?`;
|
|
1841
|
+
exitCode = 1;
|
|
1842
|
+
cleanupAndExit();
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1165
1846
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 30000);
|
|
1166
1847
|
reconnectAttempt += 1;
|
|
1167
|
-
|
|
1848
|
+
nextReconnectAt = Date.now() + delay;
|
|
1849
|
+
// Keep a live countdown on screen for the whole backoff (up to 30s), not just
|
|
1850
|
+
// the 3.5s notice flash, so the frozen viewport is obviously not live.
|
|
1851
|
+
if (!reconnectTicker) {
|
|
1852
|
+
reconnectTicker = setInterval(() => scheduleRender(), 1000);
|
|
1853
|
+
reconnectTicker.unref?.();
|
|
1854
|
+
}
|
|
1855
|
+
scheduleRender();
|
|
1168
1856
|
|
|
1169
1857
|
reconnectTimer = setTimeout(() => {
|
|
1170
1858
|
reconnectTimer = null;
|
|
@@ -1174,6 +1862,14 @@ const scheduleReconnect = () => {
|
|
|
1174
1862
|
}, delay);
|
|
1175
1863
|
};
|
|
1176
1864
|
|
|
1865
|
+
const clearReconnectTicker = () => {
|
|
1866
|
+
if (reconnectTicker) {
|
|
1867
|
+
clearInterval(reconnectTicker);
|
|
1868
|
+
reconnectTicker = null;
|
|
1869
|
+
}
|
|
1870
|
+
nextReconnectAt = 0;
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1177
1873
|
const connect = () => {
|
|
1178
1874
|
if (reconnectTimer) {
|
|
1179
1875
|
clearTimeout(reconnectTimer);
|
|
@@ -1186,18 +1882,30 @@ const connect = () => {
|
|
|
1186
1882
|
|
|
1187
1883
|
ws.on("open", () => {
|
|
1188
1884
|
connected = true;
|
|
1885
|
+
everConnected = true;
|
|
1886
|
+
lastWsError = null;
|
|
1189
1887
|
remoteMouseModes.clear();
|
|
1190
1888
|
remoteAlternateScreen = false;
|
|
1191
1889
|
remoteApplicationCursor = false;
|
|
1192
1890
|
pendingPrivateMode = "";
|
|
1891
|
+
pendingOsc = "";
|
|
1892
|
+
replayingSnapshot = false;
|
|
1193
1893
|
shortcutsEnabledAt = Date.now() + 400;
|
|
1194
1894
|
reconnectAttempt = 0;
|
|
1895
|
+
clearReconnectTicker();
|
|
1195
1896
|
setStatus("connected");
|
|
1196
1897
|
initUi();
|
|
1197
1898
|
if (process.stdin.isTTY) {
|
|
1198
1899
|
process.stdin.setRawMode(true);
|
|
1199
1900
|
}
|
|
1200
1901
|
process.stdin.resume();
|
|
1902
|
+
// Auto theme: ask the local terminal for its background color (OSC 11).
|
|
1903
|
+
// The reply arrives on stdin and is captured (and stripped) there.
|
|
1904
|
+
if (themePref === "auto" && process.stdout.isTTY && !themeQueryAsked) {
|
|
1905
|
+
themeQueryAsked = true;
|
|
1906
|
+
themeQueryDeadline = Date.now() + 2000;
|
|
1907
|
+
process.stdout.write("\x1b]11;?\x1b\\");
|
|
1908
|
+
}
|
|
1201
1909
|
if (syncSize) {
|
|
1202
1910
|
applySyncSize();
|
|
1203
1911
|
}
|
|
@@ -1213,10 +1921,28 @@ const connect = () => {
|
|
|
1213
1921
|
clientId = message.clientId;
|
|
1214
1922
|
collabMode = message.collabMode;
|
|
1215
1923
|
controllerId = message.controllerId;
|
|
1924
|
+
if (!attachNoticeShown) {
|
|
1925
|
+
attachNoticeShown = true;
|
|
1926
|
+
if (message.created === true) {
|
|
1927
|
+
// Surface accidental room creation (e.g. a typo'd room name).
|
|
1928
|
+
pushNotice(`Created new session '${sessionLabel}' — persists until killed (Ctrl+Q twice)`);
|
|
1929
|
+
} else if (message.created === false) {
|
|
1930
|
+
// Wait for the presence roster (sent right after hello) for the count.
|
|
1931
|
+
pendingAttachNotice = true;
|
|
1932
|
+
} else {
|
|
1933
|
+
// Older server without the created flag.
|
|
1934
|
+
pushNotice("Session persists if Hop exits; Ctrl+Q (twice) kills it");
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1216
1937
|
scheduleRender();
|
|
1217
1938
|
break;
|
|
1218
1939
|
case "presence":
|
|
1219
1940
|
presence = message.clients;
|
|
1941
|
+
if (pendingAttachNotice) {
|
|
1942
|
+
pendingAttachNotice = false;
|
|
1943
|
+
const count = presence.length;
|
|
1944
|
+
pushNotice(`Attached to '${sessionLabel}' (${count} participant${count === 1 ? "" : "s"})`, "ok");
|
|
1945
|
+
}
|
|
1220
1946
|
scheduleRender();
|
|
1221
1947
|
break;
|
|
1222
1948
|
case "cwd_changed":
|
|
@@ -1233,7 +1959,7 @@ const connect = () => {
|
|
|
1233
1959
|
if (followOutput) {
|
|
1234
1960
|
followCursorWithMargin();
|
|
1235
1961
|
} else {
|
|
1236
|
-
|
|
1962
|
+
restoreViewFromAnchor();
|
|
1237
1963
|
}
|
|
1238
1964
|
scheduleRender();
|
|
1239
1965
|
});
|
|
@@ -1246,10 +1972,16 @@ const connect = () => {
|
|
|
1246
1972
|
remoteAlternateScreen = message.alternateScreen;
|
|
1247
1973
|
}
|
|
1248
1974
|
trackOscColors(message.data);
|
|
1249
|
-
|
|
1975
|
+
// reset() (not clear()) so the stale cursor position, SGR attrs, and
|
|
1976
|
+
// mode state from the previous connection don't bleed into the replay.
|
|
1977
|
+
terminal.reset();
|
|
1978
|
+
releaseScrollAnchor();
|
|
1979
|
+
followOutput = true;
|
|
1250
1980
|
const filtered = filterFocusSequences(message.data);
|
|
1251
1981
|
if (!filtered) return;
|
|
1982
|
+
replayingSnapshot = true;
|
|
1252
1983
|
terminal.write(filtered, () => {
|
|
1984
|
+
replayingSnapshot = false;
|
|
1253
1985
|
if (followOutput) {
|
|
1254
1986
|
followCursorWithMargin();
|
|
1255
1987
|
} else {
|
|
@@ -1262,17 +1994,51 @@ const connect = () => {
|
|
|
1262
1994
|
case "collab":
|
|
1263
1995
|
collabMode = message.enabled;
|
|
1264
1996
|
controllerId = message.controllerId;
|
|
1265
|
-
|
|
1997
|
+
if (message.enabled) {
|
|
1998
|
+
pushNotice("Control released — everyone can type", "ok");
|
|
1999
|
+
} else if (controllerId === clientId) {
|
|
2000
|
+
pushNotice(`You have exclusive control (${keyLabelShort}+C to release)`, "ok");
|
|
2001
|
+
} else {
|
|
2002
|
+
const takerName = presence.find((c) => c.id === controllerId)?.name || "another user";
|
|
2003
|
+
pushNotice(`${takerName} took control — ${keyLabelShort}+C to take it back`, "warn");
|
|
2004
|
+
}
|
|
1266
2005
|
break;
|
|
1267
|
-
case "input_rejected":
|
|
1268
|
-
|
|
2006
|
+
case "input_rejected": {
|
|
2007
|
+
let reasonText = message.reason;
|
|
2008
|
+
if (/control is locked/i.test(message.reason)) {
|
|
2009
|
+
const lockerName = (controllerId && controllerId !== clientId
|
|
2010
|
+
? presence.find((c) => c.id === controllerId)?.name
|
|
2011
|
+
: null) || "another user";
|
|
2012
|
+
reasonText = `Control locked by ${lockerName} — ${keyLabelShort}+C to take control`;
|
|
2013
|
+
}
|
|
2014
|
+
// Rapid rejected keystrokes shouldn't restart the flash on every press.
|
|
2015
|
+
if (!(notice && notice.message === reasonText && notice.expiresAt > Date.now())) {
|
|
2016
|
+
pushNotice(reasonText, "warn");
|
|
2017
|
+
}
|
|
1269
2018
|
break;
|
|
2019
|
+
}
|
|
1270
2020
|
case "active_size":
|
|
1271
2021
|
applyRemoteSize(message.cols, message.rows);
|
|
1272
2022
|
break;
|
|
1273
|
-
case "session_ended":
|
|
2023
|
+
case "session_ended": {
|
|
1274
2024
|
shouldReconnect = false;
|
|
1275
|
-
|
|
2025
|
+
// Attribute the kill unless we requested it ourselves; surface a
|
|
2026
|
+
// non-zero exit code / signal so crashes aren't indistinguishable
|
|
2027
|
+
// from clean exits.
|
|
2028
|
+
let endText = message.by && !killRequested
|
|
2029
|
+
? `Session terminated by ${message.by}`
|
|
2030
|
+
: message.message;
|
|
2031
|
+
const endDetails: string[] = [];
|
|
2032
|
+
if (typeof message.exitCode === "number" && message.exitCode !== 0) {
|
|
2033
|
+
endDetails.push(`exit ${message.exitCode}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (message.signal) {
|
|
2036
|
+
endDetails.push(message.signal);
|
|
2037
|
+
}
|
|
2038
|
+
if (endDetails.length > 0) {
|
|
2039
|
+
endText = `${endText} (${endDetails.join(", ")})`;
|
|
2040
|
+
}
|
|
2041
|
+
exitMessage = endText;
|
|
1276
2042
|
exitCode = 0;
|
|
1277
2043
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
1278
2044
|
ws.close();
|
|
@@ -1280,9 +2046,10 @@ const connect = () => {
|
|
|
1280
2046
|
cleanupAndExit();
|
|
1281
2047
|
}
|
|
1282
2048
|
break;
|
|
2049
|
+
}
|
|
1283
2050
|
case "session_renamed":
|
|
1284
2051
|
sessionLabel = message.displayName;
|
|
1285
|
-
pushNotice(`Session renamed to ${message.displayName}
|
|
2052
|
+
pushNotice(`Session renamed to ${message.displayName}`, "ok");
|
|
1286
2053
|
scheduleRender();
|
|
1287
2054
|
break;
|
|
1288
2055
|
case "error":
|
|
@@ -1304,8 +2071,10 @@ const connect = () => {
|
|
|
1304
2071
|
});
|
|
1305
2072
|
|
|
1306
2073
|
ws.on("error", (err) => {
|
|
2074
|
+
// Remember why the connection drops so the reconnect banner can say so.
|
|
2075
|
+
lastWsError = err.message;
|
|
1307
2076
|
if (!shouldReconnect) {
|
|
1308
|
-
pushNotice(`Connection error: ${err.message}
|
|
2077
|
+
pushNotice(`Connection error: ${err.message}`, "warn");
|
|
1309
2078
|
}
|
|
1310
2079
|
});
|
|
1311
2080
|
};
|
|
@@ -1315,6 +2084,7 @@ const cleanupAndExit = () => {
|
|
|
1315
2084
|
clearTimeout(typingTimeout);
|
|
1316
2085
|
}
|
|
1317
2086
|
stopDaemonPolling();
|
|
2087
|
+
clearReconnectTicker();
|
|
1318
2088
|
if (reconnectTimer) {
|
|
1319
2089
|
clearTimeout(reconnectTimer);
|
|
1320
2090
|
}
|
|
@@ -1329,7 +2099,7 @@ const cleanupAndExit = () => {
|
|
|
1329
2099
|
if (exitMessage) {
|
|
1330
2100
|
console.log(`[hay] ${exitMessage}`);
|
|
1331
2101
|
} else if (exitCode === DETACH_EXIT_CODE) {
|
|
1332
|
-
console.log(
|
|
2102
|
+
console.log(`[hay] Detached — session continues in background. Reattach with: hay -r ${config.room}`);
|
|
1333
2103
|
} else if (exitCode === KILL_EXIT_CODE) {
|
|
1334
2104
|
console.log("[hay] Session terminated.");
|
|
1335
2105
|
} else {
|
|
@@ -1341,21 +2111,59 @@ const cleanupAndExit = () => {
|
|
|
1341
2111
|
const handleLocalShortcut = (input: string) => {
|
|
1342
2112
|
if (!input) return false;
|
|
1343
2113
|
|
|
1344
|
-
|
|
1345
|
-
|
|
2114
|
+
// Escape clears selection if active (before passing to remote)
|
|
2115
|
+
if (input === "\x1b" && selectionAnchor) {
|
|
2116
|
+
clearSelection();
|
|
2117
|
+
return true;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// Any non-mouse input clears selection
|
|
2121
|
+
if (selectionAnchor && !input.startsWith("\x1b[<")) {
|
|
2122
|
+
clearSelection();
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Opt+\ arms literal mode (consumed in the stdin handler): the next key is
|
|
2126
|
+
// forwarded verbatim so reserved keys (Ctrl+Q/G, Opt+…) can reach remote
|
|
2127
|
+
// programs.
|
|
2128
|
+
if (input === "\x1b\\" || (isMac && input === "«")) {
|
|
2129
|
+
literalNext = true;
|
|
2130
|
+
pushNotice("Next key will be sent to the remote terminal");
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Anything other than an OPEN socket (CONNECTING included) means we can't
|
|
2135
|
+
// round-trip through the server, so the Ctrl+Q/Ctrl+G handlers below exit
|
|
2136
|
+
// locally rather than poisoning state and waiting on a close that never comes.
|
|
1346
2137
|
const ctrlShortcutsReady = Date.now() >= shortcutsEnabledAt;
|
|
1347
2138
|
|
|
1348
|
-
//
|
|
2139
|
+
// During the brief post-connect grace window, swallow the reserved Ctrl keys
|
|
2140
|
+
// instead of leaking them to the remote shell.
|
|
2141
|
+
if (!ctrlShortcutsReady && (input === "\x11" || input === "\x07")) {
|
|
2142
|
+
return true;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// Ctrl+Q (twice within 2s) to kill the session for everyone
|
|
1349
2146
|
if (ctrlShortcutsReady && input === "\x11") {
|
|
2147
|
+
if (Date.now() > killArmedAt) {
|
|
2148
|
+
killArmedAt = Date.now() + KILL_CONFIRM_MS;
|
|
2149
|
+
pushNotice("Press Ctrl+Q again to kill the session for ALL participants (Ctrl+G detaches)", "warn");
|
|
2150
|
+
return true;
|
|
2151
|
+
}
|
|
2152
|
+
killArmedAt = 0;
|
|
1350
2153
|
shouldReconnect = false;
|
|
1351
|
-
exitCode = KILL_EXIT_CODE;
|
|
1352
2154
|
if (reconnectTimer) {
|
|
1353
2155
|
clearTimeout(reconnectTimer);
|
|
1354
2156
|
}
|
|
1355
2157
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
2158
|
+
killRequested = true;
|
|
2159
|
+
exitCode = KILL_EXIT_CODE;
|
|
1356
2160
|
sendMessage({ type: "kill_session" });
|
|
1357
2161
|
setTimeout(() => ws?.close(), 100);
|
|
1358
|
-
} else
|
|
2162
|
+
} else {
|
|
2163
|
+
// Nothing was killed — don't claim the session ended.
|
|
2164
|
+
exitCode = KILL_UNREACHABLE_EXIT_CODE;
|
|
2165
|
+
exitMessage = `Could not reach server — the session may still be running. Reattach with: hay -r ${config.room}`;
|
|
2166
|
+
try { ws?.close(); } catch (e) { /* connecting socket */ }
|
|
1359
2167
|
cleanupAndExit();
|
|
1360
2168
|
}
|
|
1361
2169
|
return true;
|
|
@@ -1370,48 +2178,44 @@ const handleLocalShortcut = (input: string) => {
|
|
|
1370
2178
|
}
|
|
1371
2179
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
1372
2180
|
ws.close();
|
|
1373
|
-
} else
|
|
2181
|
+
} else {
|
|
2182
|
+
try { ws?.close(); } catch (e) { /* connecting socket */ }
|
|
1374
2183
|
cleanupAndExit();
|
|
1375
2184
|
}
|
|
1376
2185
|
return true;
|
|
1377
2186
|
}
|
|
1378
2187
|
|
|
1379
|
-
// Ctrl+T toggles hint bar
|
|
1380
|
-
if (ctrlShortcutsReady && input === "\x14") {
|
|
1381
|
-
showHints = !showHints;
|
|
1382
|
-
saveConfig({ ...configFile, showHints });
|
|
1383
|
-
pushNotice(`Hints ${showHints ? "on" : "off"}`);
|
|
1384
|
-
return true;
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
2188
|
const altKey = (key: string) => input === `\x1b${key}`;
|
|
1388
2189
|
const altArrow = (seq: string) => input === seq;
|
|
1389
|
-
|
|
2190
|
+
// Option+letter shortcuts only on macOS — elsewhere these glyphs (å, µ, º…)
|
|
2191
|
+
// are real AltGr characters and the ESC-prefixed altKey forms cover the keys.
|
|
2192
|
+
const optionChar = (char: string) => isMac && input === char;
|
|
1390
2193
|
|
|
1391
|
-
|
|
1392
|
-
|
|
2194
|
+
// CSI 1;3X = Alt+Arrow, CSI 1;4X = Shift+Alt+Arrow (fast pan).
|
|
2195
|
+
if (altKey("h") || altKey("H") || altArrow("\x1b[1;3D") || altArrow("\x1b[1;4D") || altArrow("\x1b\x1b[D")) {
|
|
2196
|
+
const step = altKey("H") || altArrow("\x1b[1;4D") ? PAN_FAST_STEP : PAN_STEP;
|
|
1393
2197
|
viewX -= step;
|
|
1394
2198
|
clampView();
|
|
1395
2199
|
scheduleRender();
|
|
1396
2200
|
return true;
|
|
1397
2201
|
}
|
|
1398
|
-
if (altKey("l") || altKey("L") || altArrow("\x1b[1;3C") || altArrow("\x1b\x1b[C")) {
|
|
1399
|
-
const step = altKey("L") ? PAN_FAST_STEP : PAN_STEP;
|
|
2202
|
+
if (altKey("l") || altKey("L") || altArrow("\x1b[1;3C") || altArrow("\x1b[1;4C") || altArrow("\x1b\x1b[C")) {
|
|
2203
|
+
const step = altKey("L") || altArrow("\x1b[1;4C") ? PAN_FAST_STEP : PAN_STEP;
|
|
1400
2204
|
viewX += step;
|
|
1401
2205
|
clampView();
|
|
1402
2206
|
scheduleRender();
|
|
1403
2207
|
return true;
|
|
1404
2208
|
}
|
|
1405
|
-
if (altKey("k") || altKey("K") || altArrow("\x1b[1;3A") || altArrow("\x1b\x1b[A")) {
|
|
1406
|
-
const step = altKey("K") ? PAN_FAST_STEP : PAN_STEP;
|
|
2209
|
+
if (altKey("k") || altKey("K") || altArrow("\x1b[1;3A") || altArrow("\x1b[1;4A") || altArrow("\x1b\x1b[A")) {
|
|
2210
|
+
const step = altKey("K") || altArrow("\x1b[1;4A") ? PAN_FAST_STEP : PAN_STEP;
|
|
1407
2211
|
viewY -= step;
|
|
1408
2212
|
clampView();
|
|
1409
2213
|
updateFollowOutputFromViewport();
|
|
1410
2214
|
scheduleRender();
|
|
1411
2215
|
return true;
|
|
1412
2216
|
}
|
|
1413
|
-
if (altKey("j") || altKey("J") || altArrow("\x1b[1;3B") || altArrow("\x1b\x1b[B")) {
|
|
1414
|
-
const step = altKey("J") ? PAN_FAST_STEP : PAN_STEP;
|
|
2217
|
+
if (altKey("j") || altKey("J") || altArrow("\x1b[1;3B") || altArrow("\x1b[1;4B") || altArrow("\x1b\x1b[B")) {
|
|
2218
|
+
const step = altKey("J") || altArrow("\x1b[1;4B") ? PAN_FAST_STEP : PAN_STEP;
|
|
1415
2219
|
viewY += step;
|
|
1416
2220
|
clampView();
|
|
1417
2221
|
updateFollowOutputFromViewport();
|
|
@@ -1420,44 +2224,197 @@ const handleLocalShortcut = (input: string) => {
|
|
|
1420
2224
|
}
|
|
1421
2225
|
if (altKey("a") || altKey("A") || optionChar("å")) {
|
|
1422
2226
|
syncSize = !syncSize;
|
|
1423
|
-
|
|
2227
|
+
persistConfig({ syncSize });
|
|
2228
|
+
pushNotice(`Autofit ${syncSize ? "on" : "off"}`, "ok");
|
|
1424
2229
|
if (syncSize) {
|
|
1425
2230
|
applySyncSize();
|
|
1426
2231
|
}
|
|
1427
2232
|
return true;
|
|
1428
2233
|
}
|
|
2234
|
+
if (altKey("b") || altKey("B") || optionChar("∫")) {
|
|
2235
|
+
showStatusBar = !showStatusBar;
|
|
2236
|
+
persistConfig({ showStatusBar });
|
|
2237
|
+
pushNotice(`Status bar ${showStatusBar ? "on" : "off"}`, "ok");
|
|
2238
|
+
scheduleRender();
|
|
2239
|
+
return true;
|
|
2240
|
+
}
|
|
1429
2241
|
if (altKey("m") || altKey("M") || optionChar("µ")) {
|
|
1430
2242
|
setMouseCapture(!mouseCapture);
|
|
1431
|
-
|
|
2243
|
+
persistConfig({ mouseCapture });
|
|
2244
|
+
pushNotice(`Mouse capture ${mouseCapture ? "on" : "off"}`, "ok");
|
|
2245
|
+
return true;
|
|
2246
|
+
}
|
|
2247
|
+
if (altKey("t") || altKey("T") || optionChar("†")) {
|
|
2248
|
+
showHints = !showHints;
|
|
2249
|
+
persistConfig({ showHints });
|
|
2250
|
+
pushNotice(`Hints ${showHints ? "on" : "off"}`, "ok");
|
|
2251
|
+
scheduleRender();
|
|
1432
2252
|
return true;
|
|
1433
2253
|
}
|
|
1434
2254
|
if (altKey("0") || optionChar("º")) {
|
|
1435
2255
|
ensureCursorVisible(true);
|
|
1436
|
-
pushNotice("
|
|
2256
|
+
pushNotice("Following live output", "ok");
|
|
2257
|
+
scheduleRender();
|
|
2258
|
+
return true;
|
|
2259
|
+
}
|
|
2260
|
+
if (altKey("f") || altKey("F") || optionChar("ƒ")) {
|
|
2261
|
+
enterSearch();
|
|
2262
|
+
return true;
|
|
2263
|
+
}
|
|
2264
|
+
if (altKey("c") || altKey("C") || optionChar("ç")) {
|
|
2265
|
+
// Toggle exclusive control, mirroring the web Take/Release controls so a CLI
|
|
2266
|
+
// user on a locked shared session isn't stuck.
|
|
2267
|
+
if (!collabMode && controllerId === clientId) {
|
|
2268
|
+
sendMessage({ type: "release_control" });
|
|
2269
|
+
pushNotice("Releasing control (collaborative typing)…");
|
|
2270
|
+
} else if (collabMode) {
|
|
2271
|
+
sendMessage({ type: "take_control" });
|
|
2272
|
+
pushNotice("Taking exclusive control…");
|
|
2273
|
+
} else {
|
|
2274
|
+
sendMessage({ type: "take_control" });
|
|
2275
|
+
pushNotice("Seizing control…");
|
|
2276
|
+
}
|
|
1437
2277
|
return true;
|
|
1438
2278
|
}
|
|
1439
2279
|
|
|
1440
2280
|
return false;
|
|
1441
2281
|
};
|
|
1442
2282
|
|
|
2283
|
+
// Split a stdin chunk into atomic key tokens (ESC/CSI/SS3 sequences and single
|
|
2284
|
+
// code points). Each token is a verbatim substring, so concatenating the tokens
|
|
2285
|
+
// that aren't shortcuts reproduces the exact bytes to forward. This lets batched
|
|
2286
|
+
// keypresses (key autorepeat, SSH coalescing) match shortcuts per-key instead of
|
|
2287
|
+
// the whole chunk matching nothing and leaking raw escape sequences to the shell.
|
|
2288
|
+
const tokenizeInput = (input: string): string[] => {
|
|
2289
|
+
const tokens: string[] = [];
|
|
2290
|
+
const n = input.length;
|
|
2291
|
+
let i = 0;
|
|
2292
|
+
const consumeCsi = (start: number) => {
|
|
2293
|
+
let j = start;
|
|
2294
|
+
while (j < n && input.charCodeAt(j) >= 0x20 && input.charCodeAt(j) <= 0x3f) j += 1;
|
|
2295
|
+
if (j < n) j += 1; // include the final byte (0x40-0x7e)
|
|
2296
|
+
return j;
|
|
2297
|
+
};
|
|
2298
|
+
while (i < n) {
|
|
2299
|
+
if (input[i] === "\x1b") {
|
|
2300
|
+
if (input[i + 1] === "\x1b" && input[i + 2] === "[") {
|
|
2301
|
+
const j = consumeCsi(i + 3); // ESC ESC CSI (e.g. Alt+Arrow "\x1b\x1b[D")
|
|
2302
|
+
tokens.push(input.slice(i, j));
|
|
2303
|
+
i = j;
|
|
2304
|
+
continue;
|
|
2305
|
+
}
|
|
2306
|
+
if (input[i + 1] === "[") {
|
|
2307
|
+
const j = consumeCsi(i + 2); // CSI
|
|
2308
|
+
tokens.push(input.slice(i, j));
|
|
2309
|
+
i = j;
|
|
2310
|
+
continue;
|
|
2311
|
+
}
|
|
2312
|
+
if (input[i + 1] === "O") {
|
|
2313
|
+
const j = Math.min(i + 3, n); // SS3
|
|
2314
|
+
tokens.push(input.slice(i, j));
|
|
2315
|
+
i = j;
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
if (i + 1 < n) {
|
|
2319
|
+
const cp = input.codePointAt(i + 1) ?? 0; // ESC + single code point (alt-letter)
|
|
2320
|
+
const len = 1 + String.fromCodePoint(cp).length;
|
|
2321
|
+
tokens.push(input.slice(i, i + len));
|
|
2322
|
+
i += len;
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
tokens.push("\x1b"); // lone trailing ESC
|
|
2326
|
+
i += 1;
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
const cp = input.codePointAt(i) ?? 0;
|
|
2330
|
+
const ch = String.fromCodePoint(cp);
|
|
2331
|
+
tokens.push(ch);
|
|
2332
|
+
i += ch.length;
|
|
2333
|
+
}
|
|
2334
|
+
return tokens;
|
|
2335
|
+
};
|
|
2336
|
+
|
|
1443
2337
|
process.stdin.on("data", (data) => {
|
|
1444
2338
|
let input = pendingInput + data.toString();
|
|
1445
2339
|
pendingInput = "";
|
|
2340
|
+
// Auto theme: capture the terminal's OSC 11 background reply and keep it out
|
|
2341
|
+
// of the remote stream. The window closes 2s after the query.
|
|
2342
|
+
if (themeQueryDeadline) {
|
|
2343
|
+
if (Date.now() > themeQueryDeadline) {
|
|
2344
|
+
themeQueryDeadline = 0;
|
|
2345
|
+
} else {
|
|
2346
|
+
const reply = input.match(/\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)/);
|
|
2347
|
+
if (reply) {
|
|
2348
|
+
themeQueryDeadline = 0;
|
|
2349
|
+
input = input.replace(reply[0], "");
|
|
2350
|
+
const color = parseOscColor(reply[1]);
|
|
2351
|
+
if (color) {
|
|
2352
|
+
const luminance = (0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b) / 255;
|
|
2353
|
+
applyResolvedTheme(luminance > 0.55 ? "light" : "dark");
|
|
2354
|
+
}
|
|
2355
|
+
} else {
|
|
2356
|
+
// Reply split across chunks: stash a partial tail. Must be more than a
|
|
2357
|
+
// bare ESC so a real Escape keypress is never delayed.
|
|
2358
|
+
const partial = input.match(/\x1b(?:\](?:1(?:1(?:;[^\x07\x1b]*)?)?)?)?$/);
|
|
2359
|
+
if (partial && partial[0].length > 1) {
|
|
2360
|
+
pendingInput = partial[0];
|
|
2361
|
+
input = input.slice(0, -partial[0].length);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (!input) return;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
1446
2367
|
const lastMouseStart = input.lastIndexOf("\x1b[<");
|
|
1447
2368
|
if (lastMouseStart !== -1) {
|
|
1448
2369
|
const tail = input.slice(lastMouseStart);
|
|
1449
2370
|
if (!/[Mm]/.test(tail)) {
|
|
1450
|
-
|
|
2371
|
+
// Prepend: a stashed OSC-reply tail (above) must stay after the mouse
|
|
2372
|
+
// tail so the original stream order is preserved on the next chunk.
|
|
2373
|
+
pendingInput = tail + pendingInput;
|
|
1451
2374
|
input = input.slice(0, lastMouseStart);
|
|
1452
2375
|
}
|
|
1453
2376
|
}
|
|
2377
|
+
// A mouse sequence can also split right inside its "\x1b[<" prefix. Stash a
|
|
2378
|
+
// trailing bare ESC / "\x1b[" only when it follows other input, so a real
|
|
2379
|
+
// lone Escape keypress still goes through immediately.
|
|
2380
|
+
if (mouseCapture && !pendingInput) {
|
|
2381
|
+
const partialPrefix = input.endsWith("\x1b[") ? "\x1b[" : input.endsWith("\x1b") ? "\x1b" : "";
|
|
2382
|
+
if (partialPrefix && input.length > partialPrefix.length) {
|
|
2383
|
+
pendingInput = partialPrefix;
|
|
2384
|
+
input = input.slice(0, -partialPrefix.length);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
1454
2387
|
if (!input) return;
|
|
1455
2388
|
const mouseResult = stripMouseSequences(input);
|
|
1456
2389
|
input = mouseResult.cleaned;
|
|
1457
2390
|
if (!input) return;
|
|
1458
|
-
if (handleLocalShortcut(input)) return;
|
|
1459
2391
|
|
|
1460
|
-
|
|
2392
|
+
// While searching, all keys edit the query / navigate matches (never forwarded).
|
|
2393
|
+
if (searchMode) {
|
|
2394
|
+
handleSearchInput(input);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
let remote = "";
|
|
2399
|
+
if (literalNext) {
|
|
2400
|
+
// Opt+\ armed: bypass all local shortcuts and forward this key verbatim.
|
|
2401
|
+
literalNext = false;
|
|
2402
|
+
remote = input;
|
|
2403
|
+
} else {
|
|
2404
|
+
// Fast path: a single keypress chunk that is a shortcut (the common case).
|
|
2405
|
+
if (handleLocalShortcut(input)) return;
|
|
2406
|
+
|
|
2407
|
+
// Otherwise split into individual key tokens so a batched chunk can have its
|
|
2408
|
+
// shortcut keys handled locally while the rest is forwarded to the shell.
|
|
2409
|
+
const tokens = tokenizeInput(input);
|
|
2410
|
+
for (const token of tokens) {
|
|
2411
|
+
if (handleLocalShortcut(token)) continue;
|
|
2412
|
+
remote += token;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
if (!remote) return;
|
|
2416
|
+
|
|
2417
|
+
const sanitized = filterFocusSequences(remote);
|
|
1461
2418
|
if (!sanitized) return;
|
|
1462
2419
|
|
|
1463
2420
|
if (syncSize) {
|
|
@@ -1465,6 +2422,7 @@ process.stdin.on("data", (data) => {
|
|
|
1465
2422
|
}
|
|
1466
2423
|
|
|
1467
2424
|
followOutput = true;
|
|
2425
|
+
releaseScrollAnchor();
|
|
1468
2426
|
sendMessage({ type: "input", data: sanitized });
|
|
1469
2427
|
handleTyping();
|
|
1470
2428
|
});
|
|
@@ -1479,21 +2437,25 @@ process.stdout.on("resize", () => {
|
|
|
1479
2437
|
scheduleRender();
|
|
1480
2438
|
});
|
|
1481
2439
|
|
|
1482
|
-
|
|
2440
|
+
const handleSignalExit = () => {
|
|
1483
2441
|
shouldReconnect = false;
|
|
1484
2442
|
if (reconnectTimer) {
|
|
1485
2443
|
clearTimeout(reconnectTimer);
|
|
2444
|
+
reconnectTimer = null;
|
|
1486
2445
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
2446
|
+
// If the socket is OPEN, let its close event drive cleanup; otherwise (closed,
|
|
2447
|
+
// connecting, or mid-reconnect backoff) close() is a no-op that fires no event,
|
|
2448
|
+
// so exit directly — else the process hangs in the alternate screen forever.
|
|
2449
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2450
|
+
ws.close();
|
|
2451
|
+
} else {
|
|
2452
|
+
try { ws?.close(); } catch (e) { /* ignore */ }
|
|
2453
|
+
cleanupAndExit();
|
|
1494
2454
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
process.on("SIGINT", handleSignalExit);
|
|
2458
|
+
process.on("SIGTERM", handleSignalExit);
|
|
1497
2459
|
|
|
1498
2460
|
process.on("exit", () => {
|
|
1499
2461
|
stopDaemonPolling();
|