hop2 0.11.0 → 0.11.2
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/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +130 -0
- package/DESIGN-input-acks.md +349 -0
- package/MULTI-AGENT-CHECKLIST.md +5 -5
- package/README-MCP.md +84 -5
- package/README.md +55 -4
- package/demo/README.md +52 -0
- package/demo/capture/capture-env.mjs +108 -0
- package/demo/capture/capture.mjs +634 -0
- package/demo/capture/dispatch-task.mjs +44 -0
- package/demo/capture/mcp-client.mjs +61 -0
- package/demo/capture/native-css.mjs +49 -0
- package/demo/capture/setup-04.mjs +67 -0
- package/demo/capture/setup-sessions.mjs +40 -0
- package/demo/capture/spawn-aurora.mjs +53 -0
- package/demo/capture/tools/agent.sh +30 -0
- package/demo/capture/tools/build.sh +18 -0
- package/demo/capture/tools/metrics.sh +17 -0
- package/docs/hero-overview.svg +119 -0
- package/docs/hero.svg +118 -0
- package/docs/restore-reliability.md +139 -0
- package/docs/session-identity.md +76 -0
- package/docs/switcher-first-ux.md +103 -0
- package/hay/CHANGELOG.md +59 -1
- package/hay/apps/cli/package.json +3 -2
- package/hay/apps/cli/src/index.ts +705 -28
- package/hay/apps/server/src/pty.ts +36 -2
- package/hay/apps/server/src/rooms.ts +525 -34
- package/hay/apps/server/test/rooms.test.ts +411 -1
- package/hay/apps/web/index.html +2 -0
- package/hay/apps/web/package.json +14 -12
- package/hay/apps/web/scripts/run-e2e.mjs +42 -5
- package/hay/apps/web/src/App.tsx +2492 -281
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
- package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +2525 -0
- package/hay/apps/web/src/setupTests.ts +23 -0
- package/hay/apps/web/src/styles.css +2460 -180
- package/hay/apps/web/src/utils/fileDrop.ts +15 -0
- package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
- package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
- package/hay/apps/web/src/utils/switcherModel.ts +314 -0
- package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
- package/hay/apps/web/src/vite-env.d.ts +3 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +324 -0
- package/hay/apps/web/test/fileDrop.test.ts +22 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
- package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
- package/hay/apps/web/test/switcherModel.test.ts +377 -0
- package/hay/apps/web/test/terminalSearch.test.ts +37 -0
- package/hay/apps/web/vite.config.ts +20 -0
- package/hay/package-lock.json +1696 -1171
- package/hay/packages/shared/src/protocol.ts +25 -2
- package/hay-web/assets/index-BohAWpcL.css +1 -0
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +4 -2
- package/hop +4348 -302
- package/hopa +465 -0
- package/mcp/hop-mcp.js +2878 -122
- package/package.json +7 -4
- package/scripts/claude-session-hook.js +152 -0
- package/scripts/hay-host.js +227 -4
- package/scripts/hop-math.js +316 -0
- package/scripts/salvage-and-restart-host.js +217 -0
- package/skills/hop-manager/SKILL.md +62 -0
- package/skills/hop-session/SKILL.md +42 -0
- package/skills/hopa/SKILL.md +75 -0
- package/tests/agent-skill.unit.test.cjs +60 -0
- package/tests/composer-state.unit.test.cjs +102 -0
- package/tests/hop-mcp.integration.test.cjs +476 -94
- package/tests/hopa-parity.unit.test.cjs +188 -0
- package/tests/readable-echo.unit.test.cjs +94 -0
- package/tests/readable-flush.unit.test.cjs +93 -0
- package/tests/submit-verification.unit.test.cjs +331 -0
- package/tests/terminal-api.integration.test.cjs +301 -13
- package/hay-web/assets/index-BzJB9r_I.js +0 -64
- package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
- package/hay-web/assets/index-DG8Ntznc.css +0 -32
- package/sessions.html +0 -726
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { WebSocket } from "ws";
|
|
8
|
+
import http from "node:http";
|
|
8
9
|
import { safeParseServerMessage, type ClientMessage, type PresenceClient } from "hay-shared";
|
|
9
10
|
|
|
10
11
|
const args = process.argv.slice(2);
|
|
@@ -163,11 +164,14 @@ Keyboard shortcuts:
|
|
|
163
164
|
${keyLabelLong}+H/J/K/L Pan viewport, vim-style (Shift = faster)
|
|
164
165
|
${keyLabelLong}+0 Return to live output (center on cursor)
|
|
165
166
|
${keyLabelLong}+A Toggle autofit of remote size to window (saved)
|
|
166
|
-
${keyLabelLong}+B Toggle status bar (
|
|
167
|
+
${keyLabelLong}+B Toggle status bar (this session)
|
|
167
168
|
${keyLabelLong}+M Toggle mouse capture (saved)
|
|
168
169
|
${keyLabelLong}+C Take/release exclusive control
|
|
169
170
|
${keyLabelLong}+F Search scrollback (Enter/↓ next, ↑ prev, Esc close)
|
|
170
|
-
${keyLabelLong}+
|
|
171
|
+
${keyLabelLong}+S Session panel — list + live preview; ↑↓ fly, Enter switch, Esc close
|
|
172
|
+
${keyLabelLong}+R Rename this session (Enter save, Esc cancel)
|
|
173
|
+
${keyLabelLong}+? Show ALL shortcuts in a full-screen overlay (↑↓ scroll, Esc close)
|
|
174
|
+
${keyLabelLong}+T Toggle hint bar (this session)
|
|
171
175
|
${keyLabelLong}+\\ Send the next key literally to the remote terminal
|
|
172
176
|
(lets reserved keys like Ctrl+Q/G reach remote programs)
|
|
173
177
|
|
|
@@ -232,6 +236,14 @@ let remoteMouseModes = new Set<number>();
|
|
|
232
236
|
let remoteAlternateScreen = false;
|
|
233
237
|
let remoteApplicationCursor = false;
|
|
234
238
|
let pendingPrivateMode = "";
|
|
239
|
+
// Enhanced keyboard reporting the remote app asked for (kitty keyboard protocol /
|
|
240
|
+
// xterm modifyOtherKeys). We mirror the NET state onto the real terminal so keys like
|
|
241
|
+
// Shift+Enter get a distinct encoding that we then forward to the remote verbatim.
|
|
242
|
+
let remoteKittyKeyboard = false;
|
|
243
|
+
let remoteModifyOtherKeys = false;
|
|
244
|
+
let outerKbdEnhanced = false; // what we've actually toggled on the real terminal
|
|
245
|
+
let pendingKbdProto = "";
|
|
246
|
+
const KBD_PROTO_TAIL_CHARS = 24;
|
|
235
247
|
type RgbColor = { r: number; g: number; b: number };
|
|
236
248
|
let defaultFg: RgbColor | null = null;
|
|
237
249
|
let defaultBg: RgbColor | null = null;
|
|
@@ -264,6 +276,9 @@ let literalNext = false; // Opt+\ armed: forward the next key verbatim
|
|
|
264
276
|
let collabMode = true;
|
|
265
277
|
let controllerId: string | null = null;
|
|
266
278
|
let presence: PresenceClient[] = [];
|
|
279
|
+
// Which client last set the shared terminal size. When it's a peer (not us), the
|
|
280
|
+
// status bar shows it so a reshape from someone else's autofit isn't a mystery.
|
|
281
|
+
let activeSizeClientId: string | null = null;
|
|
267
282
|
type NoticeKind = "info" | "ok" | "warn";
|
|
268
283
|
let notice: { message: string; kind: NoticeKind; expiresAt: number } | null = null;
|
|
269
284
|
|
|
@@ -294,6 +309,34 @@ let searchMatchesByRow = new Map<number, number[]>(); // row → match start col
|
|
|
294
309
|
let searchIndex = -1;
|
|
295
310
|
const SEARCH_MAX_MATCHES = 2000;
|
|
296
311
|
|
|
312
|
+
// Session panel state (Opt+S): a file-manager-style overlay — session list on
|
|
313
|
+
// the left, a live (cached) screen preview on the right. Arrow to fly through,
|
|
314
|
+
// Enter to switch, Esc to close. Previews are fetched from the hay-host and
|
|
315
|
+
// cached so navigation has no perceived latency.
|
|
316
|
+
type PanelSession = { id: string; cwd: string; process: string; clients: number };
|
|
317
|
+
let sessionPanelMode = false;
|
|
318
|
+
let panelSessions: PanelSession[] = [];
|
|
319
|
+
let panelIndex = 0;
|
|
320
|
+
let panelLoading = false;
|
|
321
|
+
let panelError = "";
|
|
322
|
+
const panelPreviewCache = new Map<string, string[]>();
|
|
323
|
+
let switchingRoom = false;
|
|
324
|
+
|
|
325
|
+
// Help overlay (Opt+?): a full-screen list of every shortcut, so the keys are
|
|
326
|
+
// reachable even when the hint bar is too narrow to show them all.
|
|
327
|
+
let helpMode = false;
|
|
328
|
+
let helpScroll = 0;
|
|
329
|
+
// Matches Opt+? / Opt+/ in both Option-as-Meta (ESC-prefixed) and macOS
|
|
330
|
+
// Option-as-glyph forms. A bare "?" is intentionally NOT matched so it still
|
|
331
|
+
// types through to the remote.
|
|
332
|
+
const isHelpKey = (input: string) =>
|
|
333
|
+
input === "\x1b?" || input === "\x1b/" || (isMac && (input === "¿" || input === "÷"));
|
|
334
|
+
|
|
335
|
+
// Inline rename (Opt+R): edit the session's display name in place. Posts to the
|
|
336
|
+
// local hop daemon's rename API and optimistically updates the label.
|
|
337
|
+
let renameMode = false;
|
|
338
|
+
let renameQuery = "";
|
|
339
|
+
|
|
297
340
|
let configFile = loadConfig();
|
|
298
341
|
const persistConfig = (patch: Partial<CliConfig>) => {
|
|
299
342
|
configFile = { ...configFile, ...patch };
|
|
@@ -310,6 +353,9 @@ let renderScheduled = false;
|
|
|
310
353
|
let lastRenderCols = 0;
|
|
311
354
|
let lastRenderRows = 0;
|
|
312
355
|
let lastRenderedLines: string[] = []; // previous frame's lines, for dirty-line diffing
|
|
356
|
+
let scrollGeneration = 0; // bumped on every buffer scroll (xterm onScroll)
|
|
357
|
+
let lastRenderScrollGen = -1; // scrollGeneration captured at the last frame
|
|
358
|
+
let lastRenderTopRow = -1; // viewY captured at the last frame
|
|
313
359
|
|
|
314
360
|
// ---- Bar theme -------------------------------------------------------------
|
|
315
361
|
// The bars adapt to the terminal: "auto" (default) resolves light/dark from the
|
|
@@ -455,7 +501,7 @@ const getLocalMetrics = () => {
|
|
|
455
501
|
// toggles just the hint line while the status bar is shown. The reconnect
|
|
456
502
|
// banner forces the line on regardless, so a dropped connection is never silent.
|
|
457
503
|
const reconnecting = !connected && reconnectAttempt > 0;
|
|
458
|
-
const showHintBar = ((showHints && showBottomBar) || hasNotice || reconnecting || searchMode) && rows >= (showBottomBar ? 3 : 2);
|
|
504
|
+
const showHintBar = ((showHints && showBottomBar) || hasNotice || reconnecting || searchMode || renameMode) && rows >= (showBottomBar ? 3 : 2);
|
|
459
505
|
const barRows = (showBottomBar ? 1 : 0) + (showHintBar ? 1 : 0);
|
|
460
506
|
return {
|
|
461
507
|
cols,
|
|
@@ -476,7 +522,7 @@ let remoteRows = Math.max(1, localMetrics.viewportRows);
|
|
|
476
522
|
const terminal = new Terminal({
|
|
477
523
|
cols: remoteCols,
|
|
478
524
|
rows: remoteRows,
|
|
479
|
-
scrollback:
|
|
525
|
+
scrollback: 50000,
|
|
480
526
|
allowProposedApi: true
|
|
481
527
|
});
|
|
482
528
|
|
|
@@ -493,6 +539,12 @@ terminal.onData((data) => {
|
|
|
493
539
|
sendMessage({ type: "input", data });
|
|
494
540
|
});
|
|
495
541
|
|
|
542
|
+
// Bump a generation counter whenever the buffer scrolls. The dirty-line diff
|
|
543
|
+
// compares screen rows positionally, which is only valid when the buffer→screen
|
|
544
|
+
// mapping is unchanged; a scroll shifts every row's content, so the renderer
|
|
545
|
+
// forces a full repaint when this counter (or the view offset) changed.
|
|
546
|
+
terminal.onScroll(() => { scrollGeneration += 1; });
|
|
547
|
+
|
|
496
548
|
const filterFocusSequences = (data: string) => data.replace(/\x1b\[I/g, "").replace(/\x1b\[O/g, "");
|
|
497
549
|
|
|
498
550
|
const getDirectionalKey = (direction: number) => {
|
|
@@ -523,10 +575,20 @@ const stripMouseSequences = (data: string) => {
|
|
|
523
575
|
// Wheel clears any active selection
|
|
524
576
|
clearSelection();
|
|
525
577
|
const direction = wheelAxis === 1 ? 1 : -1;
|
|
526
|
-
|
|
527
|
-
|
|
578
|
+
// A mouse-aware remote (it turned on mouse tracking) gets the real wheel
|
|
579
|
+
// event — exactly like clicks/drags are passed through below — so it
|
|
580
|
+
// scrolls natively instead of being fed arrow keys. This is the point of
|
|
581
|
+
// mouse mode, and it's why the wheel was wrongly arrow-keyed before.
|
|
582
|
+
if (remoteMouseModes.size > 0) {
|
|
583
|
+
return sequence;
|
|
584
|
+
}
|
|
585
|
+
// A fullscreen app that is NOT tracking the mouse (alt-screen only):
|
|
586
|
+
// emulate the wheel with arrow keys so paging still works (less, man, a
|
|
587
|
+
// $PAGER, etc.) where there's no native mouse handling to forward to.
|
|
588
|
+
if (remoteAlternateScreen) {
|
|
528
589
|
return getDirectionalKey(direction).repeat(SCROLL_STEP);
|
|
529
590
|
}
|
|
591
|
+
// Normal screen: scroll our own viewport buffer.
|
|
530
592
|
viewY += direction * SCROLL_STEP;
|
|
531
593
|
clampView();
|
|
532
594
|
updateFollowOutputFromViewport();
|
|
@@ -1042,6 +1104,78 @@ const trackPrivateModes = (chunk: string) => {
|
|
|
1042
1104
|
pendingPrivateMode = combined.slice(-PRIVATE_MODE_TAIL_CHARS);
|
|
1043
1105
|
};
|
|
1044
1106
|
|
|
1107
|
+
// Enable/disable enhanced keyboard reporting on the REAL terminal. The remote app
|
|
1108
|
+
// (e.g. Claude Code) turns this on so it can see Shift+Enter etc., but because we
|
|
1109
|
+
// model its screen in a headless terminal and re-render, that request never reaches
|
|
1110
|
+
// the real terminal on its own — so we reflect it here. Idempotent: only writes on a
|
|
1111
|
+
// state transition, so plain-shell sessions (no app request) are byte-for-byte
|
|
1112
|
+
// unaffected.
|
|
1113
|
+
const KBD_ENABLE = "\x1b[>1u\x1b[>4;2m"; // kitty keyboard (disambiguate) + modifyOtherKeys L2
|
|
1114
|
+
const KBD_DISABLE = "\x1b[<u\x1b[>4;0m"; // pop kitty + modifyOtherKeys off
|
|
1115
|
+
const setOuterKeyboardEnhanced = (on: boolean) => {
|
|
1116
|
+
if (on === outerKbdEnhanced) return;
|
|
1117
|
+
outerKbdEnhanced = on;
|
|
1118
|
+
if (process.stdout.isTTY) process.stdout.write(on ? KBD_ENABLE : KBD_DISABLE);
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// Scan remote output for the enhanced-keyboard enable/disable sequences and reflect
|
|
1122
|
+
// the resulting NET state onto the real terminal. Claude re-asserts pop+push together
|
|
1123
|
+
// (\x1b[<u\x1b[>1u…) so we compute the final state per chunk rather than toggling.
|
|
1124
|
+
const KBD_PROTO_REGEX = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
|
|
1125
|
+
const trackKeyboardProtocol = (chunk: string) => {
|
|
1126
|
+
if (!chunk) return;
|
|
1127
|
+
const combined = pendingKbdProto + chunk;
|
|
1128
|
+
KBD_PROTO_REGEX.lastIndex = 0;
|
|
1129
|
+
let match: RegExpExecArray | null;
|
|
1130
|
+
let lastEnd = 0;
|
|
1131
|
+
while ((match = KBD_PROTO_REGEX.exec(combined)) !== null) {
|
|
1132
|
+
lastEnd = KBD_PROTO_REGEX.lastIndex;
|
|
1133
|
+
if (match[1] !== undefined) {
|
|
1134
|
+
remoteKittyKeyboard = match[1] !== "<"; // push/set = on, pop = off
|
|
1135
|
+
} else {
|
|
1136
|
+
const parts = (match[2] ?? "").split(";");
|
|
1137
|
+
if (parts[0] === "4") remoteModifyOtherKeys = (parts[1] ?? "0") !== "0";
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
setOuterKeyboardEnhanced(remoteKittyKeyboard || remoteModifyOtherKeys);
|
|
1141
|
+
// Stash a trailing partial CSI so a sequence split across chunks still resolves.
|
|
1142
|
+
const tailStart = combined.lastIndexOf("\x1b[");
|
|
1143
|
+
if (tailStart !== -1 && tailStart >= lastEnd && /^\x1b\[[<>=]?[0-9;:]*$/.test(combined.slice(tailStart))) {
|
|
1144
|
+
pendingKbdProto = combined.slice(tailStart).slice(-KBD_PROTO_TAIL_CHARS);
|
|
1145
|
+
} else {
|
|
1146
|
+
pendingKbdProto = "";
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
// When enhanced keyboard reporting is on, the real terminal encodes hop's OWN shortcut
|
|
1151
|
+
// keys in the new scheme too. Translate just those reserved combos back to their legacy
|
|
1152
|
+
// bytes so Ctrl+G/Ctrl+Q and the Opt+… shortcuts keep working; everything else is
|
|
1153
|
+
// forwarded to the remote verbatim. Returns null when the token isn't a reserved key.
|
|
1154
|
+
const RESERVED_CTRL_CODES = new Map<number, string>([[103, "\x07"], [113, "\x11"]]); // Ctrl+G, Ctrl+Q
|
|
1155
|
+
const normalizeReservedKey = (token: string): string | null => {
|
|
1156
|
+
if (!outerKbdEnhanced) return null;
|
|
1157
|
+
let code: number;
|
|
1158
|
+
let mods: number;
|
|
1159
|
+
const kitty = /^\x1b\[(\d+)(?:;(\d+))?u$/.exec(token);
|
|
1160
|
+
if (kitty) {
|
|
1161
|
+
code = Number.parseInt(kitty[1], 10);
|
|
1162
|
+
mods = kitty[2] ? Number.parseInt(kitty[2], 10) : 1;
|
|
1163
|
+
} else {
|
|
1164
|
+
const modOther = /^\x1b\[27;(\d+);(\d+)~$/.exec(token);
|
|
1165
|
+
if (!modOther) return null;
|
|
1166
|
+
mods = Number.parseInt(modOther[1], 10);
|
|
1167
|
+
code = Number.parseInt(modOther[2], 10);
|
|
1168
|
+
}
|
|
1169
|
+
const bits = mods - 1;
|
|
1170
|
+
const shift = (bits & 1) !== 0;
|
|
1171
|
+
const alt = (bits & 2) !== 0;
|
|
1172
|
+
const ctrl = (bits & 4) !== 0;
|
|
1173
|
+
if (ctrl && !alt && RESERVED_CTRL_CODES.has(code)) return RESERVED_CTRL_CODES.get(code) ?? null;
|
|
1174
|
+
if (alt && !ctrl && code >= 97 && code <= 122) return "\x1b" + String.fromCharCode(shift ? code - 32 : code);
|
|
1175
|
+
if (alt && !ctrl && code === 92) return "\x1b\\"; // Opt+\ (arms literal mode)
|
|
1176
|
+
return null;
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1045
1179
|
terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
|
|
1046
1180
|
applyPrivateModes(params, false);
|
|
1047
1181
|
return false;
|
|
@@ -1064,6 +1198,16 @@ const pushNotice = (message: string, kind: NoticeKind = "info") => {
|
|
|
1064
1198
|
scheduleRender();
|
|
1065
1199
|
};
|
|
1066
1200
|
|
|
1201
|
+
// Brand context: when a host product (hop) spawns this CLI it sets
|
|
1202
|
+
// HAY_BRAND and HAY_REATTACH_CMD so user-facing messages speak the host's
|
|
1203
|
+
// language ("[hop] ... Reattach with: hop attach <room>") instead of leaking
|
|
1204
|
+
// the internal hay layer. Standalone use keeps the hay defaults.
|
|
1205
|
+
const BRAND = process.env.HAY_BRAND || "hay";
|
|
1206
|
+
const reattachHint = (room: string) =>
|
|
1207
|
+
process.env.HAY_REATTACH_CMD
|
|
1208
|
+
? `${process.env.HAY_REATTACH_CMD} ${room}`
|
|
1209
|
+
: `hay -r ${room}`;
|
|
1210
|
+
|
|
1067
1211
|
const sendMessage = (message: ClientMessage) => {
|
|
1068
1212
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
1069
1213
|
ws.send(JSON.stringify(message));
|
|
@@ -1123,10 +1267,16 @@ const handleTyping = () => {
|
|
|
1123
1267
|
};
|
|
1124
1268
|
|
|
1125
1269
|
const getUrl = () => {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1270
|
+
// config.server may already carry query params (e.g. `hop attach` bakes in
|
|
1271
|
+
// room/name/source/cwd). Use searchParams.set so switching rooms REPLACES the
|
|
1272
|
+
// baked-in room= instead of appending a second one — the server reads the first
|
|
1273
|
+
// room= value, so an appended duplicate is ignored and switches never take effect.
|
|
1274
|
+
const url = new URL(config.server);
|
|
1275
|
+
url.searchParams.set("room", config.room);
|
|
1276
|
+
url.searchParams.set("name", config.name);
|
|
1277
|
+
url.searchParams.set("cols", String(Math.max(2, localMetrics.viewportCols)));
|
|
1278
|
+
url.searchParams.set("rows", String(Math.max(2, localMetrics.viewportRows)));
|
|
1279
|
+
return url.toString();
|
|
1130
1280
|
};
|
|
1131
1281
|
|
|
1132
1282
|
const isPidAlive = (pid: number) => {
|
|
@@ -1206,6 +1356,12 @@ const setStatus = (nextStatus: typeof status) => {
|
|
|
1206
1356
|
scheduleRender();
|
|
1207
1357
|
};
|
|
1208
1358
|
|
|
1359
|
+
// One-shot autofit claim per connection (see the active_size handler):
|
|
1360
|
+
// "pending" = armed at connect, waiting for the room's true size;
|
|
1361
|
+
// "claimed" = claim sent, the next active_size may be the corrective echo;
|
|
1362
|
+
// "done" = steady state, peer reshape notices apply.
|
|
1363
|
+
let sizeClaimPhase: "pending" | "claimed" | "done" = "done";
|
|
1364
|
+
|
|
1209
1365
|
const applyRemoteSize = (cols: number, rows: number) => {
|
|
1210
1366
|
localMetrics = getLocalMetrics();
|
|
1211
1367
|
const nextCols = Math.max(2, Math.min(cols, 500));
|
|
@@ -1223,7 +1379,7 @@ const applyRemoteSize = (cols: number, rows: number) => {
|
|
|
1223
1379
|
scheduleRender();
|
|
1224
1380
|
};
|
|
1225
1381
|
|
|
1226
|
-
const applySyncSize = () => {
|
|
1382
|
+
const applySyncSize = (claim?: "attach") => {
|
|
1227
1383
|
if (!syncSize) return;
|
|
1228
1384
|
localMetrics = getLocalMetrics();
|
|
1229
1385
|
const cols = localMetrics.viewportCols;
|
|
@@ -1232,8 +1388,10 @@ const applySyncSize = () => {
|
|
|
1232
1388
|
applyRemoteSize(cols, rows);
|
|
1233
1389
|
if (needsResize) {
|
|
1234
1390
|
// Protocol rejects resize below 2x2 — clamp so tiny terminals don't send
|
|
1235
|
-
// messages the server refuses.
|
|
1236
|
-
|
|
1391
|
+
// messages the server refuses. claim:"attach" (the one-shot claim at
|
|
1392
|
+
// connect) beats the 60s idle election unless a peer typed seconds ago —
|
|
1393
|
+
// attaching is intent, same as the web client opening a session.
|
|
1394
|
+
sendMessage({ type: "resize", cols: Math.max(2, cols), rows: Math.max(2, rows), claim });
|
|
1237
1395
|
}
|
|
1238
1396
|
};
|
|
1239
1397
|
|
|
@@ -1277,6 +1435,9 @@ const initUi = () => {
|
|
|
1277
1435
|
const restoreUi = () => {
|
|
1278
1436
|
if (!uiInitialized || !process.stdout.isTTY) return;
|
|
1279
1437
|
disableMouseCapture();
|
|
1438
|
+
// Undo any enhanced keyboard reporting we turned on, so the shell we return to
|
|
1439
|
+
// isn't left sending kitty/modifyOtherKeys encodings.
|
|
1440
|
+
setOuterKeyboardEnhanced(false);
|
|
1280
1441
|
process.stdout.write("\x1b[?25h\x1b[?1049l\x1b[0m");
|
|
1281
1442
|
uiInitialized = false;
|
|
1282
1443
|
};
|
|
@@ -1663,6 +1824,384 @@ const renderLine = (lineIndex: number, cursorRow: number, cursorCol: number) =>
|
|
|
1663
1824
|
return output;
|
|
1664
1825
|
};
|
|
1665
1826
|
|
|
1827
|
+
// ===== Session panel (Opt+S): file-manager-style switcher with preview =====
|
|
1828
|
+
const panelHttpOrigin = inferServerOrigin(); // http://host:port of the hay-host
|
|
1829
|
+
|
|
1830
|
+
const fetchHostJson = (pathname: string): Promise<any> =>
|
|
1831
|
+
new Promise((resolve) => {
|
|
1832
|
+
if (!panelHttpOrigin) { resolve(null); return; }
|
|
1833
|
+
let settled = false;
|
|
1834
|
+
const done = (v: any) => { if (!settled) { settled = true; resolve(v); } };
|
|
1835
|
+
try {
|
|
1836
|
+
const u = new URL(panelHttpOrigin + pathname);
|
|
1837
|
+
const req = http.request(
|
|
1838
|
+
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: "GET" },
|
|
1839
|
+
(res) => {
|
|
1840
|
+
if ((res.statusCode || 0) !== 200) { res.resume(); done(null); return; }
|
|
1841
|
+
let data = "";
|
|
1842
|
+
res.on("data", (c) => (data += c));
|
|
1843
|
+
res.on("end", () => { try { done(JSON.parse(data)); } catch { done(null); } });
|
|
1844
|
+
}
|
|
1845
|
+
);
|
|
1846
|
+
req.on("error", () => done(null));
|
|
1847
|
+
req.setTimeout(2000, () => { req.destroy(); done(null); });
|
|
1848
|
+
req.end();
|
|
1849
|
+
} catch { done(null); }
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
// Render a preview source ({cols,rows,output}) to plain visible-screen lines via
|
|
1853
|
+
// a throwaway headless terminal (same approach the daemon uses for the web).
|
|
1854
|
+
const renderPreviewToLines = (source: { cols: number; rows: number; output: string }): Promise<string[]> =>
|
|
1855
|
+
new Promise((resolve) => {
|
|
1856
|
+
const cols = Math.max(2, Math.min(300, Math.floor(Number(source.cols) || 80)));
|
|
1857
|
+
const rows = Math.max(2, Math.min(120, Math.floor(Number(source.rows) || 24)));
|
|
1858
|
+
let t: ReturnType<typeof createPreviewTerm>;
|
|
1859
|
+
try {
|
|
1860
|
+
t = createPreviewTerm(cols, rows);
|
|
1861
|
+
} catch { resolve(["(preview unavailable)"]); return; }
|
|
1862
|
+
t.write(typeof source.output === "string" ? source.output : "", () => {
|
|
1863
|
+
const buf = t.buffer.active;
|
|
1864
|
+
const out: string[] = [];
|
|
1865
|
+
for (let i = 0; i < rows; i++) {
|
|
1866
|
+
const line = buf.getLine(buf.baseY + i);
|
|
1867
|
+
out.push(line ? line.translateToString(true) : "");
|
|
1868
|
+
}
|
|
1869
|
+
try { t.dispose(); } catch { /* ignore */ }
|
|
1870
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
1871
|
+
resolve(out.length ? out : ["(blank screen)"]);
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
const createPreviewTerm = (cols: number, rows: number) =>
|
|
1875
|
+
new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
|
1876
|
+
|
|
1877
|
+
const loadPanelPreview = async (id: string) => {
|
|
1878
|
+
if (panelPreviewCache.has(id)) return;
|
|
1879
|
+
const source = await fetchHostJson(`/rooms/${encodeURIComponent(id)}/preview`);
|
|
1880
|
+
panelPreviewCache.set(id, source ? await renderPreviewToLines(source) : ["(no preview)"]);
|
|
1881
|
+
if (sessionPanelMode) scheduleRender();
|
|
1882
|
+
};
|
|
1883
|
+
|
|
1884
|
+
const openSessionPanel = async () => {
|
|
1885
|
+
sessionPanelMode = true;
|
|
1886
|
+
panelLoading = true;
|
|
1887
|
+
panelError = "";
|
|
1888
|
+
lastRenderedLines = [];
|
|
1889
|
+
scheduleRender();
|
|
1890
|
+
const data = await fetchHostJson("/rooms");
|
|
1891
|
+
if (!sessionPanelMode) return; // closed while loading
|
|
1892
|
+
panelLoading = false;
|
|
1893
|
+
const rooms = Array.isArray(data?.rooms) ? data.rooms : null;
|
|
1894
|
+
if (!rooms) { panelError = "Could not list sessions (is the host reachable?)"; scheduleRender(); return; }
|
|
1895
|
+
panelSessions = rooms
|
|
1896
|
+
.map((r: any): PanelSession => ({
|
|
1897
|
+
id: String(r?.id || ""),
|
|
1898
|
+
cwd: typeof r?.liveCwd === "string" && r.liveCwd ? r.liveCwd : (typeof r?.cwd === "string" ? r.cwd : ""),
|
|
1899
|
+
process: (typeof r?.foregroundProcess === "string" ? r.foregroundProcess : "").replace(/^-/, ""),
|
|
1900
|
+
clients: Number(r?.clientCount) || 0
|
|
1901
|
+
}))
|
|
1902
|
+
.filter((r: PanelSession) => r.id)
|
|
1903
|
+
.sort((a: PanelSession, b: PanelSession) => a.id.localeCompare(b.id));
|
|
1904
|
+
const cur = panelSessions.findIndex((r) => r.id === config.room);
|
|
1905
|
+
panelIndex = cur >= 0 ? cur : 0;
|
|
1906
|
+
panelPreviewCache.clear();
|
|
1907
|
+
scheduleRender();
|
|
1908
|
+
// Prefetch previews (selected first, then the rest) so flying has no latency.
|
|
1909
|
+
const order = [panelIndex, ...panelSessions.map((_, i) => i).filter((i) => i !== panelIndex)];
|
|
1910
|
+
for (const i of order) { const s = panelSessions[i]; if (s) loadPanelPreview(s.id); }
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1913
|
+
const closeSessionPanel = () => {
|
|
1914
|
+
if (!sessionPanelMode) return;
|
|
1915
|
+
sessionPanelMode = false;
|
|
1916
|
+
lastRenderedLines = [];
|
|
1917
|
+
scheduleRender();
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
const movePanelSelection = (delta: number) => {
|
|
1921
|
+
if (!panelSessions.length) return;
|
|
1922
|
+
panelIndex = (panelIndex + delta + panelSessions.length) % panelSessions.length;
|
|
1923
|
+
const s = panelSessions[panelIndex];
|
|
1924
|
+
if (s) loadPanelPreview(s.id);
|
|
1925
|
+
scheduleRender();
|
|
1926
|
+
};
|
|
1927
|
+
|
|
1928
|
+
const switchToRoom = (id: string) => {
|
|
1929
|
+
if (!id || id === config.room) { closeSessionPanel(); return; }
|
|
1930
|
+
closeSessionPanel();
|
|
1931
|
+
config.room = id;
|
|
1932
|
+
sessionLabel = id;
|
|
1933
|
+
terminal.reset();
|
|
1934
|
+
lastRenderedLines = [];
|
|
1935
|
+
followOutput = true;
|
|
1936
|
+
releaseScrollAnchor();
|
|
1937
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
1938
|
+
clearReconnectTicker();
|
|
1939
|
+
reconnectAttempt = 0;
|
|
1940
|
+
pushNotice(`Switching to '${id}'…`);
|
|
1941
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
1942
|
+
switchingRoom = true;
|
|
1943
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
1944
|
+
} else {
|
|
1945
|
+
connect();
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
const handlePanelInput = (input: string) => {
|
|
1950
|
+
if (input === "\x1b" || input === "\x1bs" || input === "\x1bS" || (isMac && input === "ß")) { closeSessionPanel(); return; }
|
|
1951
|
+
if (input === "\x1b[A" || input === "\x1bOA" || input === "k") { movePanelSelection(-1); return; }
|
|
1952
|
+
if (input === "\x1b[B" || input === "\x1bOB" || input === "j") { movePanelSelection(1); return; }
|
|
1953
|
+
if (input === "\r" || input === "\n") {
|
|
1954
|
+
const s = panelSessions[panelIndex];
|
|
1955
|
+
if (s) switchToRoom(s.id);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
// Everything else is swallowed while the panel is open.
|
|
1959
|
+
};
|
|
1960
|
+
|
|
1961
|
+
const renderSessionPanel = (): string[] => {
|
|
1962
|
+
const cols = localMetrics.cols;
|
|
1963
|
+
const rows = localMetrics.rows;
|
|
1964
|
+
const reset = "\x1b[0m";
|
|
1965
|
+
const cell = (text: string, width: number) => {
|
|
1966
|
+
const t = truncateAnsi(text, width);
|
|
1967
|
+
return t + " ".repeat(Math.max(0, width - visibleLength(t)));
|
|
1968
|
+
};
|
|
1969
|
+
const lines: string[] = [];
|
|
1970
|
+
|
|
1971
|
+
// Header
|
|
1972
|
+
const hint = panelLoading
|
|
1973
|
+
? "loading…"
|
|
1974
|
+
: `${panelSessions.length} session${panelSessions.length === 1 ? "" : "s"} · ↑↓ move · ⏎ switch · Esc close`;
|
|
1975
|
+
const header = ` ${BAR.bold}Sessions${BAR.resetFg} ${BAR.dim}${hint}${BAR.resetFg}`;
|
|
1976
|
+
lines.push(`${BAR.statusBg}${BAR.fg}${padOrTrim(header, cols)}${reset}`);
|
|
1977
|
+
|
|
1978
|
+
const bodyH = Math.max(1, rows - 2);
|
|
1979
|
+
// Wider list column so each row can show its working dir next to the name.
|
|
1980
|
+
const listW = Math.max(26, Math.min(46, Math.floor(cols * 0.40)));
|
|
1981
|
+
const prevW = Math.max(10, cols - listW - 3);
|
|
1982
|
+
const listStart = panelSessions.length <= bodyH
|
|
1983
|
+
? 0
|
|
1984
|
+
: Math.max(0, Math.min(panelIndex - Math.floor(bodyH / 2), panelSessions.length - bodyH));
|
|
1985
|
+
|
|
1986
|
+
const sel = panelSessions[panelIndex];
|
|
1987
|
+
let previewLines: string[];
|
|
1988
|
+
if (panelLoading) previewLines = ["Loading sessions…"];
|
|
1989
|
+
else if (panelError) previewLines = [panelError];
|
|
1990
|
+
else if (!panelSessions.length) previewLines = ["No sessions."];
|
|
1991
|
+
else if (sel) previewLines = panelPreviewCache.get(sel.id) || ["loading preview…"];
|
|
1992
|
+
else previewLines = [];
|
|
1993
|
+
const prevStart = Math.max(0, previewLines.length - bodyH);
|
|
1994
|
+
|
|
1995
|
+
for (let b = 0; b < bodyH; b++) {
|
|
1996
|
+
let left: string;
|
|
1997
|
+
const absIdx = listStart + b;
|
|
1998
|
+
if (!panelLoading && absIdx < panelSessions.length) {
|
|
1999
|
+
const s = panelSessions[absIdx];
|
|
2000
|
+
const selected = absIdx === panelIndex;
|
|
2001
|
+
const isCurrent = s.id === config.room;
|
|
2002
|
+
// Session name on the left; working dir dimmed and right-aligned, truncated to fit.
|
|
2003
|
+
const namePart = truncateAnsi(`${selected ? "▸ " : " "}${s.id}${isCurrent ? " ·here" : ""}`, listW);
|
|
2004
|
+
const nameW = visibleLength(namePart);
|
|
2005
|
+
const avail = Math.max(0, listW - nameW - 1);
|
|
2006
|
+
const cwdText = s.cwd ? formatCwd(s.cwd) : "";
|
|
2007
|
+
const cwd = avail > 0 && cwdText ? truncateAnsi(cwdText, avail) : "";
|
|
2008
|
+
const gap = " ".repeat(Math.max(1, listW - nameW - visibleLength(cwd)));
|
|
2009
|
+
left = selected
|
|
2010
|
+
? `${BAR.chip}${namePart}${gap}${cwd}\x1b[22m${reset}`
|
|
2011
|
+
: `${BAR.fg}${namePart}${reset}${gap}${BAR.dim}${cwd}${reset}`;
|
|
2012
|
+
} else {
|
|
2013
|
+
left = cell("", listW);
|
|
2014
|
+
}
|
|
2015
|
+
const pline = previewLines[prevStart + b] ?? "";
|
|
2016
|
+
const right = `${BAR.dim}${cell(pline, prevW)}${reset}`;
|
|
2017
|
+
lines.push(`${left} ${BAR.dim}│${reset} ${right}\x1b[K`);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// Footer: selected session's working dir + running program
|
|
2021
|
+
let footer: string;
|
|
2022
|
+
if (sel && !panelLoading && !panelError) {
|
|
2023
|
+
const meta = [
|
|
2024
|
+
sel.cwd ? formatCwd(sel.cwd) : "",
|
|
2025
|
+
sel.process ? `running ${sel.process}` : "",
|
|
2026
|
+
sel.clients ? `${sel.clients} viewer${sel.clients === 1 ? "" : "s"}` : ""
|
|
2027
|
+
].filter(Boolean).join(" · ");
|
|
2028
|
+
footer = ` ${BAR.bold}${sel.id}${BAR.resetFg} ${BAR.dim}${meta}${BAR.resetFg}`;
|
|
2029
|
+
} else {
|
|
2030
|
+
footer = ` ${BAR.dim}${panelError || "Opt+S to close"}${BAR.resetFg}`;
|
|
2031
|
+
}
|
|
2032
|
+
lines.push(`${BAR.hintBg}${BAR.fg}${padOrTrim(footer, cols)}${reset}`);
|
|
2033
|
+
|
|
2034
|
+
return lines;
|
|
2035
|
+
};
|
|
2036
|
+
|
|
2037
|
+
const openHelp = () => {
|
|
2038
|
+
helpMode = true;
|
|
2039
|
+
helpScroll = 0;
|
|
2040
|
+
lastRenderedLines = [];
|
|
2041
|
+
scheduleRender();
|
|
2042
|
+
};
|
|
2043
|
+
|
|
2044
|
+
const closeHelp = () => {
|
|
2045
|
+
if (!helpMode) return;
|
|
2046
|
+
helpMode = false;
|
|
2047
|
+
lastRenderedLines = [];
|
|
2048
|
+
scheduleRender();
|
|
2049
|
+
};
|
|
2050
|
+
|
|
2051
|
+
const handleHelpInput = (input: string) => {
|
|
2052
|
+
if (input === "\x1b" || input === "q" || isHelpKey(input)) { closeHelp(); return; }
|
|
2053
|
+
if (input === "\x1b[A" || input === "\x1bOA" || input === "k") {
|
|
2054
|
+
helpScroll = Math.max(0, helpScroll - 1); scheduleRender(); return;
|
|
2055
|
+
}
|
|
2056
|
+
if (input === "\x1b[B" || input === "\x1bOB" || input === "j") {
|
|
2057
|
+
helpScroll += 1; scheduleRender(); return;
|
|
2058
|
+
}
|
|
2059
|
+
// Everything else is swallowed while help is open.
|
|
2060
|
+
};
|
|
2061
|
+
|
|
2062
|
+
// ── Inline rename (Opt+R) ──
|
|
2063
|
+
const enterRename = () => {
|
|
2064
|
+
renameMode = true;
|
|
2065
|
+
renameQuery = "";
|
|
2066
|
+
scheduleRender();
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
// The local hop daemon's API address + auth, read from ~/.hop2/.tunnel-state
|
|
2070
|
+
// (the same file the daemon writes its pid/port/secret to). Null when there's no
|
|
2071
|
+
// reachable local daemon (e.g. a remote `hop client`).
|
|
2072
|
+
const readDaemonApi = (): { port: number; secret: string } | null => {
|
|
2073
|
+
try {
|
|
2074
|
+
const parsed = JSON.parse(fs.readFileSync(hopTunnelStateFile, "utf8"));
|
|
2075
|
+
if (!isPidAlive(Number(parsed?.pid))) return null;
|
|
2076
|
+
const port = Number(parsed?.port);
|
|
2077
|
+
const secret = typeof parsed?.sessionSecret === "string" ? parsed.sessionSecret : "";
|
|
2078
|
+
if (!Number.isInteger(port) || port <= 0 || !secret) return null;
|
|
2079
|
+
return { port, secret };
|
|
2080
|
+
} catch {
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
|
|
2085
|
+
const submitRename = (raw: string) => {
|
|
2086
|
+
const newName = raw.trim();
|
|
2087
|
+
if (!newName || newName === sessionLabel || newName === config.room) { scheduleRender(); return; }
|
|
2088
|
+
const api = readDaemonApi();
|
|
2089
|
+
if (!api) {
|
|
2090
|
+
pushNotice("Rename needs the local hop daemon (not available here)", "warn");
|
|
2091
|
+
scheduleRender();
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
const body = JSON.stringify({ oldName: config.room, newName });
|
|
2095
|
+
const req = http.request(
|
|
2096
|
+
{
|
|
2097
|
+
hostname: "127.0.0.1", port: api.port, path: "/api/sessions/rename", method: "POST",
|
|
2098
|
+
headers: {
|
|
2099
|
+
"Content-Type": "application/json",
|
|
2100
|
+
"Content-Length": Buffer.byteLength(body),
|
|
2101
|
+
Authorization: `Bearer ${api.secret}`,
|
|
2102
|
+
},
|
|
2103
|
+
},
|
|
2104
|
+
(res) => {
|
|
2105
|
+
let data = "";
|
|
2106
|
+
res.on("data", (c) => (data += c));
|
|
2107
|
+
res.on("end", () => {
|
|
2108
|
+
const ok = (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300;
|
|
2109
|
+
if (ok) {
|
|
2110
|
+
sessionLabel = newName;
|
|
2111
|
+
pushNotice(`Renamed to ${newName}`, "ok");
|
|
2112
|
+
} else {
|
|
2113
|
+
let msg = `status ${res.statusCode}`;
|
|
2114
|
+
try { msg = JSON.parse(data)?.error || msg; } catch { /* keep status */ }
|
|
2115
|
+
pushNotice(`Rename failed: ${msg}`, "warn");
|
|
2116
|
+
}
|
|
2117
|
+
scheduleRender();
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
);
|
|
2121
|
+
req.on("error", (e) => { pushNotice(`Rename failed: ${e.message}`, "warn"); scheduleRender(); });
|
|
2122
|
+
req.setTimeout(2500, () => { req.destroy(); pushNotice("Rename timed out", "warn"); scheduleRender(); });
|
|
2123
|
+
req.write(body);
|
|
2124
|
+
req.end();
|
|
2125
|
+
};
|
|
2126
|
+
|
|
2127
|
+
const handleRenameInput = (input: string) => {
|
|
2128
|
+
if (input === "\x1b") { renameMode = false; scheduleRender(); return; }
|
|
2129
|
+
if (input === "\r" || input === "\n") {
|
|
2130
|
+
const q = renameQuery;
|
|
2131
|
+
renameMode = false;
|
|
2132
|
+
submitRename(q);
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
if (input === "\x7f" || input === "\b") { renameQuery = renameQuery.slice(0, -1); scheduleRender(); return; }
|
|
2136
|
+
// Accept only characters valid in a session name; ignore the rest (control
|
|
2137
|
+
// sequences, spaces, etc.) so the result is always a legal name.
|
|
2138
|
+
const cleaned = input.replace(/[^A-Za-z0-9_-]/g, "");
|
|
2139
|
+
if (cleaned) { renameQuery = (renameQuery + cleaned).slice(0, 64); scheduleRender(); }
|
|
2140
|
+
};
|
|
2141
|
+
|
|
2142
|
+
// Full-screen list of every shortcut, grouped. Reachable even when the hint bar
|
|
2143
|
+
// is too narrow to show them all (which is the whole point of this overlay).
|
|
2144
|
+
const renderHelpOverlay = (): string[] => {
|
|
2145
|
+
const cols = localMetrics.cols;
|
|
2146
|
+
const rows = localMetrics.rows;
|
|
2147
|
+
const reset = "\x1b[0m";
|
|
2148
|
+
const kOpt = (k: string) => (isMac ? `⌥${k}` : `Alt+${k}`);
|
|
2149
|
+
const kCtrl = (k: string) => (isMac ? `⌃${k}` : `Ctrl+${k}`);
|
|
2150
|
+
const groups: Array<[string, Array<[string, string]>]> = [
|
|
2151
|
+
["Viewport", [
|
|
2152
|
+
[kOpt("←/→/↑/↓"), "pan the view"],
|
|
2153
|
+
[kOpt("H/J/K/L"), "pan (vim keys)"],
|
|
2154
|
+
["Shift + pan", "pan faster"],
|
|
2155
|
+
[kOpt("0"), "jump back to live output"],
|
|
2156
|
+
[kOpt("A"), "toggle autofit"],
|
|
2157
|
+
]],
|
|
2158
|
+
["Session", [
|
|
2159
|
+
[kCtrl("G"), "detach (leave it running)"],
|
|
2160
|
+
[`${kCtrl("Q")} ×2`, "kill the session"],
|
|
2161
|
+
[kOpt("S"), "session switcher"],
|
|
2162
|
+
[kOpt("R"), "rename this session"],
|
|
2163
|
+
[kOpt("C"), "take / release control"],
|
|
2164
|
+
]],
|
|
2165
|
+
["Display", [
|
|
2166
|
+
[kOpt("B"), "status bar on/off (this session)"],
|
|
2167
|
+
[kOpt("T"), "hints on/off (this session)"],
|
|
2168
|
+
[kOpt("M"), "mouse capture on/off"],
|
|
2169
|
+
]],
|
|
2170
|
+
["Input", [
|
|
2171
|
+
[kOpt("F"), "search the scrollback"],
|
|
2172
|
+
[kOpt("\\"), "send the next key literally"],
|
|
2173
|
+
[kOpt("?"), "this help"],
|
|
2174
|
+
]],
|
|
2175
|
+
];
|
|
2176
|
+
|
|
2177
|
+
const body: string[] = [];
|
|
2178
|
+
for (const [title, items] of groups) {
|
|
2179
|
+
if (body.length) body.push("");
|
|
2180
|
+
body.push(` ${BAR.bold}${title}${BAR.resetFg}`);
|
|
2181
|
+
const keyW = Math.max(...items.map(([k]) => visibleLength(k)));
|
|
2182
|
+
for (const [k, desc] of items) {
|
|
2183
|
+
const key = k + " ".repeat(Math.max(0, keyW - visibleLength(k)));
|
|
2184
|
+
body.push(` ${BAR.fg}${key}${reset} ${BAR.dim}${desc}${reset}`);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const bodyH = Math.max(1, rows - 2);
|
|
2189
|
+
const maxScroll = Math.max(0, body.length - bodyH);
|
|
2190
|
+
if (helpScroll > maxScroll) helpScroll = maxScroll;
|
|
2191
|
+
|
|
2192
|
+
const lines: string[] = [];
|
|
2193
|
+
const atEnd = helpScroll >= maxScroll;
|
|
2194
|
+
const scrollHint = maxScroll > 0 ? (atEnd ? "↑ more" : "↑↓ scroll") : "";
|
|
2195
|
+
const header = ` ${BAR.bold}Keyboard shortcuts${BAR.resetFg} ${BAR.dim}${scrollHint}${scrollHint ? " · " : ""}Esc close${BAR.resetFg}`;
|
|
2196
|
+
lines.push(`${BAR.statusBg}${BAR.fg}${padOrTrim(header, cols)}${reset}`);
|
|
2197
|
+
for (let b = 0; b < bodyH; b++) {
|
|
2198
|
+
lines.push(`${padOrTrim(body[helpScroll + b] ?? "", cols)}\x1b[K`);
|
|
2199
|
+
}
|
|
2200
|
+
const footer = ` ${BAR.dim}${kOpt("?")} or Esc to close${maxScroll > 0 && !atEnd ? " · more below ↓" : ""}${BAR.resetFg}`;
|
|
2201
|
+
lines.push(`${BAR.hintBg}${BAR.fg}${padOrTrim(footer, cols)}${reset}`);
|
|
2202
|
+
return lines;
|
|
2203
|
+
};
|
|
2204
|
+
|
|
1666
2205
|
const render = () => {
|
|
1667
2206
|
if (!process.stdout.isTTY) return;
|
|
1668
2207
|
initUi();
|
|
@@ -1678,6 +2217,34 @@ const render = () => {
|
|
|
1678
2217
|
lastRenderedLines = []; // dimensions changed — repaint everything
|
|
1679
2218
|
forceFull = true;
|
|
1680
2219
|
}
|
|
2220
|
+
// The dirty-line diff compares screen rows positionally; that's only valid
|
|
2221
|
+
// when the buffer→screen mapping is stable. A scroll (buffer scrolled, or the
|
|
2222
|
+
// user panned the viewport) shifts every row's content, so a positional diff
|
|
2223
|
+
// would skip rows that matched a stale neighbour (lost text) and leave moved
|
|
2224
|
+
// rows behind (duplicated text). Force a full repaint whenever the view
|
|
2225
|
+
// offset or scroll generation changed since the last frame.
|
|
2226
|
+
if (!sessionPanelMode && !helpMode && (viewY !== lastRenderTopRow || scrollGeneration !== lastRenderScrollGen)) {
|
|
2227
|
+
forceFull = true;
|
|
2228
|
+
}
|
|
2229
|
+
lastRenderTopRow = viewY;
|
|
2230
|
+
lastRenderScrollGen = scrollGeneration;
|
|
2231
|
+
|
|
2232
|
+
// Session panel / help overlays take over the whole screen when open.
|
|
2233
|
+
if (sessionPanelMode || helpMode) {
|
|
2234
|
+
const olines = helpMode ? renderHelpOverlay() : renderSessionPanel();
|
|
2235
|
+
let output = "\x1b[?25l";
|
|
2236
|
+
let changed = 0;
|
|
2237
|
+
for (let i = 0; i < localMetrics.rows; i++) {
|
|
2238
|
+
const line = olines[i] ?? "\x1b[K";
|
|
2239
|
+
if (forceFull || line !== lastRenderedLines[i]) {
|
|
2240
|
+
output += `\x1b[${i + 1};1H` + line;
|
|
2241
|
+
changed += 1;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
lastRenderedLines = olines;
|
|
2245
|
+
if (changed > 0) process.stdout.write(output);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
1681
2248
|
|
|
1682
2249
|
const others = presence.filter((client) => client.id !== clientId);
|
|
1683
2250
|
const controllerName = controllerId
|
|
@@ -1714,12 +2281,19 @@ const render = () => {
|
|
|
1714
2281
|
const peerNames = others.map((c) => (c.typing ? `${c.name}*` : c.name)).join(", ");
|
|
1715
2282
|
const peerLabel = others.length ? `peers: ${peerNames}` : "";
|
|
1716
2283
|
const controlLabel = (!collabMode && controllerName) ? `locked: ${controllerName}` : "";
|
|
2284
|
+
// When a peer is driving the shared size, surface it so a different shape is
|
|
2285
|
+
// explained at a glance (the transient notice covers the moment it changes).
|
|
2286
|
+
const sizeDriverName = (activeSizeClientId && activeSizeClientId !== clientId)
|
|
2287
|
+
? others.find((c) => c.id === activeSizeClientId)?.name
|
|
2288
|
+
: null;
|
|
2289
|
+
const sizeDriverLabel = sizeDriverName ? `⇄ ${sizeDriverName}` : "";
|
|
1717
2290
|
const candidateSegments: Array<BarSegment | null> = [
|
|
1718
2291
|
scrollLabel ? { text: scrollLabel } : null,
|
|
1719
2292
|
statusLabel ? { text: statusLabel } : null,
|
|
1720
2293
|
hopDaemonLabel ? { text: hopDaemonLabel, secondary: true, dropPriority: 1 } : null,
|
|
1721
2294
|
!statusLabel && shareUrl ? { text: shareUrl, secondary: true, dropPriority: 0 } : null,
|
|
1722
2295
|
autofitLabel ? { text: autofitLabel, secondary: true, dropPriority: 3 } : null,
|
|
2296
|
+
sizeDriverLabel ? { text: sizeDriverLabel, secondary: true, dropPriority: 5 } : null,
|
|
1723
2297
|
peerLabel ? { text: peerLabel, secondary: true, dropPriority: 4 } : null,
|
|
1724
2298
|
controlLabel ? { text: controlLabel, secondary: true, dropPriority: 6 } : null,
|
|
1725
2299
|
{ text: "● " } // state dot, rightmost; no dropPriority — always visible
|
|
@@ -1760,12 +2334,15 @@ const render = () => {
|
|
|
1760
2334
|
const hintPairs: Array<[string, string]> = [
|
|
1761
2335
|
[kCtrl("G"), "detach"],
|
|
1762
2336
|
[kCtrl("Q"), "kill"],
|
|
2337
|
+
[kOpt("?"), "help"],
|
|
1763
2338
|
[kOpt("←→↑↓"), "pan"],
|
|
1764
2339
|
[kOpt("0"), "live"],
|
|
1765
2340
|
[kOpt("A"), "autofit"],
|
|
1766
2341
|
[kOpt("B"), `status ${showStatusBar ? "on" : "off"}`],
|
|
1767
2342
|
[kOpt("M"), `mouse ${mouseCapture ? "on" : "off"}`],
|
|
1768
2343
|
[kOpt("F"), "find"],
|
|
2344
|
+
[kOpt("S"), "sessions"],
|
|
2345
|
+
[kOpt("R"), "rename"],
|
|
1769
2346
|
[kOpt("C"), "control"],
|
|
1770
2347
|
[kOpt("T"), "hints"],
|
|
1771
2348
|
[kOpt("\\"), "literal"]
|
|
@@ -1773,7 +2350,9 @@ const render = () => {
|
|
|
1773
2350
|
const controls = ` ${hintPairs.map(([key, label]) => `${BAR.fg}${key} ${BAR.dim}${label}`).join(" · ")}`;
|
|
1774
2351
|
const reconnecting = !connected && reconnectAttempt > 0;
|
|
1775
2352
|
let hintInner: string;
|
|
1776
|
-
if (
|
|
2353
|
+
if (renameMode) {
|
|
2354
|
+
hintInner = `${BAR.accent} rename: ${renameQuery}▏${BAR.resetFg}${BAR.dim} Enter save · Esc cancel${BAR.resetFg}`;
|
|
2355
|
+
} else if (searchMode) {
|
|
1777
2356
|
const count = searchQuery
|
|
1778
2357
|
? (searchMatches.length > 0
|
|
1779
2358
|
? `${searchIndex + 1}/${searchMatches.length}`
|
|
@@ -1788,6 +2367,12 @@ const render = () => {
|
|
|
1788
2367
|
} else {
|
|
1789
2368
|
hintInner = noticeText || controls;
|
|
1790
2369
|
}
|
|
2370
|
+
// When the controls overflow a narrow window, end with a dim ellipsis so it's
|
|
2371
|
+
// clear there are more hints than fit — Opt+? (kept near the front so it
|
|
2372
|
+
// survives truncation) opens the full list.
|
|
2373
|
+
if (hintInner === controls && visibleLength(controls) > localMetrics.cols && localMetrics.cols > 2) {
|
|
2374
|
+
hintInner = truncateAnsi(controls, localMetrics.cols - 2) + `${BAR.dim} …${BAR.resetFg}`;
|
|
2375
|
+
}
|
|
1791
2376
|
const hintLine = renderBar(
|
|
1792
2377
|
composeBar(hintInner, "", localMetrics.cols),
|
|
1793
2378
|
"hint"
|
|
@@ -1886,6 +2471,11 @@ const connect = () => {
|
|
|
1886
2471
|
lastWsError = null;
|
|
1887
2472
|
remoteMouseModes.clear();
|
|
1888
2473
|
remoteAlternateScreen = false;
|
|
2474
|
+
// Recompute enhanced-keyboard state from the fresh snapshot; leave the real
|
|
2475
|
+
// terminal as-is (setOuterKeyboardEnhanced reconciles it idempotently).
|
|
2476
|
+
remoteKittyKeyboard = false;
|
|
2477
|
+
remoteModifyOtherKeys = false;
|
|
2478
|
+
pendingKbdProto = "";
|
|
1889
2479
|
remoteApplicationCursor = false;
|
|
1890
2480
|
pendingPrivateMode = "";
|
|
1891
2481
|
pendingOsc = "";
|
|
@@ -1909,6 +2499,10 @@ const connect = () => {
|
|
|
1909
2499
|
if (syncSize) {
|
|
1910
2500
|
applySyncSize();
|
|
1911
2501
|
}
|
|
2502
|
+
// The sync above is a no-op on a fresh connect: the local size model
|
|
2503
|
+
// starts equal to this window, and the room's true size is only learned
|
|
2504
|
+
// from the first active_size. Arm a one-shot claim for that moment.
|
|
2505
|
+
sizeClaimPhase = "pending";
|
|
1912
2506
|
scheduleRender();
|
|
1913
2507
|
});
|
|
1914
2508
|
|
|
@@ -1952,6 +2546,7 @@ const connect = () => {
|
|
|
1952
2546
|
case "output":
|
|
1953
2547
|
{
|
|
1954
2548
|
trackPrivateModes(message.data);
|
|
2549
|
+
trackKeyboardProtocol(message.data);
|
|
1955
2550
|
trackOscColors(message.data);
|
|
1956
2551
|
const filtered = filterFocusSequences(message.data);
|
|
1957
2552
|
if (!filtered) return;
|
|
@@ -1971,10 +2566,19 @@ const connect = () => {
|
|
|
1971
2566
|
if (typeof message.alternateScreen === "boolean") {
|
|
1972
2567
|
remoteAlternateScreen = message.alternateScreen;
|
|
1973
2568
|
}
|
|
2569
|
+
// The enable sequence may predate the retained buffer (long-running app), so
|
|
2570
|
+
// trust the server's tracked state to restore enhanced keyboard reporting.
|
|
2571
|
+
if (typeof message.keyboardEnhanced === "boolean") {
|
|
2572
|
+
remoteKittyKeyboard = message.keyboardEnhanced;
|
|
2573
|
+
remoteModifyOtherKeys = message.keyboardEnhanced;
|
|
2574
|
+
setOuterKeyboardEnhanced(message.keyboardEnhanced);
|
|
2575
|
+
}
|
|
2576
|
+
trackKeyboardProtocol(message.data);
|
|
1974
2577
|
trackOscColors(message.data);
|
|
1975
2578
|
// reset() (not clear()) so the stale cursor position, SGR attrs, and
|
|
1976
2579
|
// mode state from the previous connection don't bleed into the replay.
|
|
1977
2580
|
terminal.reset();
|
|
2581
|
+
lastRenderedLines = []; // post-reset buffer is unrelated to the last frame — repaint fully
|
|
1978
2582
|
releaseScrollAnchor();
|
|
1979
2583
|
followOutput = true;
|
|
1980
2584
|
const filtered = filterFocusSequences(message.data);
|
|
@@ -2017,9 +2621,35 @@ const connect = () => {
|
|
|
2017
2621
|
}
|
|
2018
2622
|
break;
|
|
2019
2623
|
}
|
|
2020
|
-
case "active_size":
|
|
2624
|
+
case "active_size": {
|
|
2625
|
+
const prevCols = remoteCols;
|
|
2626
|
+
const prevRows = remoteRows;
|
|
2627
|
+
activeSizeClientId = message.clientId || null;
|
|
2021
2628
|
applyRemoteSize(message.cols, message.rows);
|
|
2629
|
+
// Autofit-at-attach: the first active_size is where we learn the
|
|
2630
|
+
// room's real size, so this is the moment to claim it (the server's
|
|
2631
|
+
// idle election decides). One-shot: if the claim loses, the server's
|
|
2632
|
+
// corrective echo lands here as phase "claimed" and must not re-claim
|
|
2633
|
+
// (that would loop) or fire the reshape notice (nobody resized).
|
|
2634
|
+
const claimPhase = sizeClaimPhase;
|
|
2635
|
+
if (claimPhase === "pending") {
|
|
2636
|
+
sizeClaimPhase = "claimed";
|
|
2637
|
+
if (syncSize) {
|
|
2638
|
+
applySyncSize("attach");
|
|
2639
|
+
}
|
|
2640
|
+
} else if (claimPhase === "claimed") {
|
|
2641
|
+
sizeClaimPhase = "done";
|
|
2642
|
+
}
|
|
2643
|
+
// A peer's autofit (or manual resize) just reshaped the shared terminal —
|
|
2644
|
+
// say who and to what, so the new shape isn't a surprise.
|
|
2645
|
+
if (claimPhase === "done" && message.clientId && message.clientId !== clientId &&
|
|
2646
|
+
(message.cols !== prevCols || message.rows !== prevRows)) {
|
|
2647
|
+
const who = presence.find((c) => c.id === message.clientId)?.name || "another client";
|
|
2648
|
+
pushNotice(`${who} resized the terminal to ${message.cols}×${message.rows}`, "info");
|
|
2649
|
+
}
|
|
2650
|
+
scheduleRender();
|
|
2022
2651
|
break;
|
|
2652
|
+
}
|
|
2023
2653
|
case "session_ended": {
|
|
2024
2654
|
shouldReconnect = false;
|
|
2025
2655
|
// Attribute the kill unless we requested it ourselves; surface a
|
|
@@ -2062,6 +2692,11 @@ const connect = () => {
|
|
|
2062
2692
|
|
|
2063
2693
|
ws.on("close", () => {
|
|
2064
2694
|
connected = false;
|
|
2695
|
+
if (switchingRoom) {
|
|
2696
|
+
switchingRoom = false;
|
|
2697
|
+
connect(); // reconnect immediately to the newly-selected room
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2065
2700
|
if (shouldReconnect) {
|
|
2066
2701
|
setStatus("reconnecting");
|
|
2067
2702
|
scheduleReconnect();
|
|
@@ -2097,13 +2732,13 @@ const cleanupAndExit = () => {
|
|
|
2097
2732
|
process.stdin.pause();
|
|
2098
2733
|
restoreUi();
|
|
2099
2734
|
if (exitMessage) {
|
|
2100
|
-
console.log(`[
|
|
2735
|
+
console.log(`[${BRAND}] ${exitMessage}`);
|
|
2101
2736
|
} else if (exitCode === DETACH_EXIT_CODE) {
|
|
2102
|
-
console.log(`[
|
|
2737
|
+
console.log(`[${BRAND}] Detached — session continues in background. Reattach with: ${reattachHint(config.room)}`);
|
|
2103
2738
|
} else if (exitCode === KILL_EXIT_CODE) {
|
|
2104
|
-
console.log(
|
|
2739
|
+
console.log(`[${BRAND}] Session terminated.`);
|
|
2105
2740
|
} else {
|
|
2106
|
-
console.log(
|
|
2741
|
+
console.log(`[${BRAND}] Disconnected`);
|
|
2107
2742
|
}
|
|
2108
2743
|
process.exit(exitCode);
|
|
2109
2744
|
};
|
|
@@ -2162,7 +2797,7 @@ const handleLocalShortcut = (input: string) => {
|
|
|
2162
2797
|
} else {
|
|
2163
2798
|
// Nothing was killed — don't claim the session ended.
|
|
2164
2799
|
exitCode = KILL_UNREACHABLE_EXIT_CODE;
|
|
2165
|
-
exitMessage = `Could not reach server — the session may still be running. Reattach with:
|
|
2800
|
+
exitMessage = `Could not reach server — the session may still be running. Reattach with: ${reattachHint(config.room)}`;
|
|
2166
2801
|
try { ws?.close(); } catch (e) { /* connecting socket */ }
|
|
2167
2802
|
cleanupAndExit();
|
|
2168
2803
|
}
|
|
@@ -2232,9 +2867,10 @@ const handleLocalShortcut = (input: string) => {
|
|
|
2232
2867
|
return true;
|
|
2233
2868
|
}
|
|
2234
2869
|
if (altKey("b") || altKey("B") || optionChar("∫")) {
|
|
2870
|
+
// Session-only: don't persist, so an in-session toggle never becomes the
|
|
2871
|
+
// default. The default comes from .hop.json (showStatusBar, default true).
|
|
2235
2872
|
showStatusBar = !showStatusBar;
|
|
2236
|
-
|
|
2237
|
-
pushNotice(`Status bar ${showStatusBar ? "on" : "off"}`, "ok");
|
|
2873
|
+
pushNotice(`Status bar ${showStatusBar ? "on" : "off"} (this session)`, "ok");
|
|
2238
2874
|
scheduleRender();
|
|
2239
2875
|
return true;
|
|
2240
2876
|
}
|
|
@@ -2245,9 +2881,10 @@ const handleLocalShortcut = (input: string) => {
|
|
|
2245
2881
|
return true;
|
|
2246
2882
|
}
|
|
2247
2883
|
if (altKey("t") || altKey("T") || optionChar("†")) {
|
|
2884
|
+
// Session-only (see Opt+B): the default stays whatever .hop.json says
|
|
2885
|
+
// (showHints, default true); toggling here doesn't rewrite it.
|
|
2248
2886
|
showHints = !showHints;
|
|
2249
|
-
|
|
2250
|
-
pushNotice(`Hints ${showHints ? "on" : "off"}`, "ok");
|
|
2887
|
+
pushNotice(`Hints ${showHints ? "on" : "off"} (this session)`, "ok");
|
|
2251
2888
|
scheduleRender();
|
|
2252
2889
|
return true;
|
|
2253
2890
|
}
|
|
@@ -2261,6 +2898,18 @@ const handleLocalShortcut = (input: string) => {
|
|
|
2261
2898
|
enterSearch();
|
|
2262
2899
|
return true;
|
|
2263
2900
|
}
|
|
2901
|
+
if (altKey("s") || altKey("S") || optionChar("ß")) {
|
|
2902
|
+
void openSessionPanel();
|
|
2903
|
+
return true;
|
|
2904
|
+
}
|
|
2905
|
+
if (isHelpKey(input)) {
|
|
2906
|
+
openHelp();
|
|
2907
|
+
return true;
|
|
2908
|
+
}
|
|
2909
|
+
if (altKey("r") || altKey("R") || optionChar("®")) {
|
|
2910
|
+
enterRename();
|
|
2911
|
+
return true;
|
|
2912
|
+
}
|
|
2264
2913
|
if (altKey("c") || altKey("C") || optionChar("ç")) {
|
|
2265
2914
|
// Toggle exclusive control, mirroring the web Take/Release controls so a CLI
|
|
2266
2915
|
// user on a locked shared session isn't stuck.
|
|
@@ -2389,6 +3038,24 @@ process.stdin.on("data", (data) => {
|
|
|
2389
3038
|
input = mouseResult.cleaned;
|
|
2390
3039
|
if (!input) return;
|
|
2391
3040
|
|
|
3041
|
+
// While renaming, keys edit the name (never forwarded).
|
|
3042
|
+
if (renameMode) {
|
|
3043
|
+
handleRenameInput(input);
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// While the help overlay is open, keys drive it (never forwarded).
|
|
3048
|
+
if (helpMode) {
|
|
3049
|
+
handleHelpInput(input);
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// While the session panel is open, keys drive the panel (never forwarded).
|
|
3054
|
+
if (sessionPanelMode) {
|
|
3055
|
+
handlePanelInput(input);
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2392
3059
|
// While searching, all keys edit the query / navigate matches (never forwarded).
|
|
2393
3060
|
if (searchMode) {
|
|
2394
3061
|
handleSearchInput(input);
|
|
@@ -2408,6 +3075,14 @@ process.stdin.on("data", (data) => {
|
|
|
2408
3075
|
// shortcut keys handled locally while the rest is forwarded to the shell.
|
|
2409
3076
|
const tokens = tokenizeInput(input);
|
|
2410
3077
|
for (const token of tokens) {
|
|
3078
|
+
// With enhanced keyboard reporting on, hop's shortcut keys arrive re-encoded;
|
|
3079
|
+
// translate just those back so they still trigger locally.
|
|
3080
|
+
const legacy = normalizeReservedKey(token);
|
|
3081
|
+
if (legacy !== null) {
|
|
3082
|
+
if (handleLocalShortcut(legacy)) continue;
|
|
3083
|
+
remote += token; // reserved-looking but not a live shortcut → forward the real key
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
2411
3086
|
if (handleLocalShortcut(token)) continue;
|
|
2412
3087
|
remote += token;
|
|
2413
3088
|
}
|
|
@@ -2417,13 +3092,15 @@ process.stdin.on("data", (data) => {
|
|
|
2417
3092
|
const sanitized = filterFocusSequences(remote);
|
|
2418
3093
|
if (!sanitized) return;
|
|
2419
3094
|
|
|
2420
|
-
if (syncSize) {
|
|
2421
|
-
applySyncSize();
|
|
2422
|
-
}
|
|
2423
|
-
|
|
2424
3095
|
followOutput = true;
|
|
2425
3096
|
releaseScrollAnchor();
|
|
2426
3097
|
sendMessage({ type: "input", data: sanitized });
|
|
3098
|
+
// Autofit AFTER the input: the server elects the resize winner by typing
|
|
3099
|
+
// recency, so the keystroke we just sent is what makes this claim win on
|
|
3100
|
+
// the first key instead of silently losing to a stale election.
|
|
3101
|
+
if (syncSize) {
|
|
3102
|
+
applySyncSize();
|
|
3103
|
+
}
|
|
2427
3104
|
handleTyping();
|
|
2428
3105
|
});
|
|
2429
3106
|
|