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.
Files changed (82) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/publish.yml +130 -0
  3. package/DESIGN-input-acks.md +349 -0
  4. package/MULTI-AGENT-CHECKLIST.md +5 -5
  5. package/README-MCP.md +84 -5
  6. package/README.md +55 -4
  7. package/demo/README.md +52 -0
  8. package/demo/capture/capture-env.mjs +108 -0
  9. package/demo/capture/capture.mjs +634 -0
  10. package/demo/capture/dispatch-task.mjs +44 -0
  11. package/demo/capture/mcp-client.mjs +61 -0
  12. package/demo/capture/native-css.mjs +49 -0
  13. package/demo/capture/setup-04.mjs +67 -0
  14. package/demo/capture/setup-sessions.mjs +40 -0
  15. package/demo/capture/spawn-aurora.mjs +53 -0
  16. package/demo/capture/tools/agent.sh +30 -0
  17. package/demo/capture/tools/build.sh +18 -0
  18. package/demo/capture/tools/metrics.sh +17 -0
  19. package/docs/hero-overview.svg +119 -0
  20. package/docs/hero.svg +118 -0
  21. package/docs/restore-reliability.md +139 -0
  22. package/docs/session-identity.md +76 -0
  23. package/docs/switcher-first-ux.md +103 -0
  24. package/hay/CHANGELOG.md +59 -1
  25. package/hay/apps/cli/package.json +3 -2
  26. package/hay/apps/cli/src/index.ts +705 -28
  27. package/hay/apps/server/src/pty.ts +36 -2
  28. package/hay/apps/server/src/rooms.ts +525 -34
  29. package/hay/apps/server/test/rooms.test.ts +411 -1
  30. package/hay/apps/web/index.html +2 -0
  31. package/hay/apps/web/package.json +14 -12
  32. package/hay/apps/web/scripts/run-e2e.mjs +42 -5
  33. package/hay/apps/web/src/App.tsx +2492 -281
  34. package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
  35. package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
  36. package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
  37. package/hay/apps/web/src/components/SessionSwitcher.tsx +2525 -0
  38. package/hay/apps/web/src/setupTests.ts +23 -0
  39. package/hay/apps/web/src/styles.css +2460 -180
  40. package/hay/apps/web/src/utils/fileDrop.ts +15 -0
  41. package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
  42. package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
  43. package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
  44. package/hay/apps/web/src/utils/switcherModel.ts +314 -0
  45. package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
  46. package/hay/apps/web/src/vite-env.d.ts +3 -0
  47. package/hay/apps/web/test/SessionSwitcher.test.tsx +324 -0
  48. package/hay/apps/web/test/fileDrop.test.ts +22 -0
  49. package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
  50. package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
  51. package/hay/apps/web/test/switcherModel.test.ts +377 -0
  52. package/hay/apps/web/test/terminalSearch.test.ts +37 -0
  53. package/hay/apps/web/vite.config.ts +20 -0
  54. package/hay/package-lock.json +1696 -1171
  55. package/hay/packages/shared/src/protocol.ts +25 -2
  56. package/hay-web/assets/index-BohAWpcL.css +1 -0
  57. package/hay-web/assets/index-DfGT3DuO.js +130 -0
  58. package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
  59. package/hay-web/index.html +4 -2
  60. package/hop +4348 -302
  61. package/hopa +465 -0
  62. package/mcp/hop-mcp.js +2878 -122
  63. package/package.json +7 -4
  64. package/scripts/claude-session-hook.js +152 -0
  65. package/scripts/hay-host.js +227 -4
  66. package/scripts/hop-math.js +316 -0
  67. package/scripts/salvage-and-restart-host.js +217 -0
  68. package/skills/hop-manager/SKILL.md +62 -0
  69. package/skills/hop-session/SKILL.md +42 -0
  70. package/skills/hopa/SKILL.md +75 -0
  71. package/tests/agent-skill.unit.test.cjs +60 -0
  72. package/tests/composer-state.unit.test.cjs +102 -0
  73. package/tests/hop-mcp.integration.test.cjs +476 -94
  74. package/tests/hopa-parity.unit.test.cjs +188 -0
  75. package/tests/readable-echo.unit.test.cjs +94 -0
  76. package/tests/readable-flush.unit.test.cjs +93 -0
  77. package/tests/submit-verification.unit.test.cjs +331 -0
  78. package/tests/terminal-api.integration.test.cjs +301 -13
  79. package/hay-web/assets/index-BzJB9r_I.js +0 -64
  80. package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
  81. package/hay-web/assets/index-DG8Ntznc.css +0 -32
  82. 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 (saved)
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}+T Toggle hint bar (saved)
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: 5000,
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
- const isTuiLikeMode = remoteAlternateScreen || remoteMouseModes.size > 0;
527
- if (isTuiLikeMode) {
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
- const separator = config.server.includes("?") ? "&" : "?";
1127
- return `${config.server}${separator}room=${encodeURIComponent(config.room)}&name=${encodeURIComponent(
1128
- config.name
1129
- )}&cols=${Math.max(2, localMetrics.viewportCols)}&rows=${Math.max(2, localMetrics.viewportRows)}`;
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
- sendMessage({ type: "resize", cols: Math.max(2, cols), rows: Math.max(2, rows) });
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 (searchMode) {
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(`[hay] ${exitMessage}`);
2735
+ console.log(`[${BRAND}] ${exitMessage}`);
2101
2736
  } else if (exitCode === DETACH_EXIT_CODE) {
2102
- console.log(`[hay] Detached — session continues in background. Reattach with: hay -r ${config.room}`);
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("[hay] Session terminated.");
2739
+ console.log(`[${BRAND}] Session terminated.`);
2105
2740
  } else {
2106
- console.log("[hay] Disconnected");
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: hay -r ${config.room}`;
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
- persistConfig({ showStatusBar });
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
- persistConfig({ showHints });
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