hop2 0.10.0 → 0.11.0

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