hop2 0.11.4 → 0.11.5

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.
@@ -15,7 +15,7 @@ import { WebglAddon } from "@xterm/addon-webgl";
15
15
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
16
16
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
17
17
  import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
18
- import { bufferLooksLikeClaude, createVoiceHold } from "../utils/voiceHold";
18
+ import { createVoiceHold, isClaudeSurface } from "../utils/voiceHold";
19
19
  import {
20
20
  buildSwitcherModel,
21
21
  filterSessionsByOrigin,
@@ -162,7 +162,11 @@ const PREVIEW_BASE_LH = PREVIEW_BASE_FS * 1.3;
162
162
  let previewCharW = 0;
163
163
  const advanceCache = new Map<number, number>();
164
164
  if (typeof document !== "undefined") {
165
- document.fonts?.ready?.then(() => { previewCharW = 0; advanceCache.clear(); }).catch(() => { /* no Font API */ });
165
+ document.fonts?.ready?.then(() => {
166
+ previewCharW = 0;
167
+ advanceCache.clear();
168
+ charHeightCache.clear();
169
+ }).catch(() => { /* no Font API */ });
166
170
  }
167
171
  const measureAdvanceAt = (fs: number) => {
168
172
  const hit = advanceCache.get(fs);
@@ -196,6 +200,33 @@ const tileLetterSpacing = (fs: number) => {
196
200
  return Math.floor(adv * dpr) / dpr - adv;
197
201
  };
198
202
 
203
+ // Row height, the vertical half of the same story. xterm derives it from the
204
+ // FONT'S natural line box (line-height:normal), not from fontSize — at 12px
205
+ // Menlo that is 14px, so xterm's row is 18px while `fontSize * 1.3` says
206
+ // 15.6. The preview was ~15% short per row: bottom-anchored, so the newest
207
+ // lines looked right and everything above crept — the residual
208
+ // preview → terminal shift on sessions with content up the screen.
209
+ // Rule (probe-measured, exact at every size and dpr):
210
+ // row = floor(naturalCharHeight × lineHeight × dpr) / dpr
211
+ const charHeightCache = new Map<number, number>();
212
+ const measureCharHeightAt = (fs: number) => {
213
+ const hit = charHeightCache.get(fs);
214
+ if (hit) return hit;
215
+ const el = document.createElement("div");
216
+ el.style.cssText = `position:absolute;visibility:hidden;white-space:pre;line-height:normal;font-size:${fs}px`;
217
+ el.style.fontFamily = "var(--font-terminal)";
218
+ el.textContent = "W";
219
+ document.body.appendChild(el);
220
+ const h = el.getBoundingClientRect().height || fs * 1.2;
221
+ el.remove();
222
+ charHeightCache.set(fs, h);
223
+ return h;
224
+ };
225
+ const tileRowHeight = (fs: number) => {
226
+ const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
227
+ return Math.floor(measureCharHeightAt(fs) * 1.3 * dpr) / dpr;
228
+ };
229
+
199
230
  // ONE font for the whole wall at a given zoom, derived from the box width —
200
231
  // never from any session's geometry. Both live tiles and static previews use
201
232
  // this, which is what makes the wall read as one surface instead of a
@@ -212,9 +243,11 @@ const previewScaleObserver = typeof ResizeObserver !== "undefined"
212
243
  const inner = box.firstElementChild as HTMLElement | null;
213
244
  if (!inner) continue;
214
245
  const fs2 = tileFontFor(box.clientWidth);
246
+ const rowH = tileRowHeight(fs2);
215
247
  inner.style.fontSize = fs2 + "px";
216
- inner.style.lineHeight = Math.round(fs2 * 1.3) + "px";
248
+ inner.style.lineHeight = rowH + "px";
217
249
  inner.style.letterSpacing = tileLetterSpacing(fs2) + "px";
250
+ inner.style.setProperty("--tile-row-h", rowH + "px");
218
251
  }
219
252
  })
220
253
  : null;
@@ -239,7 +272,12 @@ const ScaledScreen = ({ frame }: { frame: PreviewFrame }) => {
239
272
  <div className="switcher-preview-scalebox" ref={boxRef}>
240
273
  <div
241
274
  className="switcher-preview-screen"
242
- style={{ fontSize: PREVIEW_BASE_FS, lineHeight: `${PREVIEW_BASE_LH}px`, letterSpacing: `${tileLetterSpacing(PREVIEW_BASE_FS)}px` }}
275
+ style={{
276
+ fontSize: PREVIEW_BASE_FS,
277
+ lineHeight: `${tileRowHeight(PREVIEW_BASE_FS)}px`,
278
+ letterSpacing: `${tileLetterSpacing(PREVIEW_BASE_FS)}px`,
279
+ ["--tile-row-h" as string]: `${tileRowHeight(PREVIEW_BASE_FS)}px`
280
+ } as React.CSSProperties}
243
281
  >
244
282
  {renderPreviewRuns(padded)}
245
283
  </div>
@@ -312,16 +350,20 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
312
350
  // "voice doesn't always work".
313
351
  const [voiceInterim, setVoiceInterim] = useState<string | null>(null);
314
352
  const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
353
+ // Is this tile showing Claude Code? Decides KEY ENCODING (Shift+Enter's
354
+ // CSI-u) and voice eligibility, so it has to be right on restored
355
+ // sessions too. The `claudeApp` prop reads the session's foreground
356
+ // process, which an argv-launched restore reports as the wrapper shell
357
+ // FOREVER (`shell -lc "claude …; exec shell -l"`) — so it is false for
358
+ // every session that came back through `hop restore`. The buffer's own
359
+ // chrome is the honest witness; both callers share it.
360
+ const looksLikeClaude = () => isClaudeSurface(claudeAppRef.current, termRef.current as never);
315
361
  if (!voiceHoldRef.current) {
316
362
  voiceHoldRef.current = createVoiceHold({
317
363
  send: (data) => sendInputRef.current?.(data),
318
364
  notify: (m) => onNoticeRef.current?.(m),
319
365
  setOverlay: setVoiceInterim,
320
- eligible: () => {
321
- if (claudeAppRef.current) return true;
322
- const t = termRef.current;
323
- return !!t && bufferLooksLikeClaude(t as never);
324
- }
366
+ eligible: () => looksLikeClaude()
325
367
  });
326
368
  }
327
369
 
@@ -329,6 +371,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
329
371
  // find bar. Scope follows focus — ⌘F in a live tile searches THIS session;
330
372
  // ⌘F anywhere else is the wall filter, which finds sessions.
331
373
  const [find, setFind] = useState<{ query: string; index: number; total: number } | null>(null);
374
+ // The session's grid does not fit this tile — someone else (a full-screen
375
+ // client, the CLI, another person) holds the size, and our claim was
376
+ // declined or never made. The tile then crops silently, which reads as a
377
+ // rendering bug rather than "this terminal is bigger than this box". Track
378
+ // the overflow so the tile can SAY so, and let the user pan to the rest.
379
+ const [offGrid, setOffGrid] = useState<{ x: number; y: number; cols: number; rows: number } | null>(null);
380
+ const offGridRef = useRef<{ x: number; y: number; cols: number; rows: number } | null>(null);
381
+ // User's pan offset within the oversized terminal (px, <= 0). Null = follow
382
+ // the automatic bottom anchor.
383
+ const panRef = useRef<{ x: number; y: number } | null>(null);
332
384
  const findInputRef = useRef<HTMLInputElement | null>(null);
333
385
  const findIndexRef = useRef(-1);
334
386
  const runTileFind = (query: string, step = 0) => {
@@ -434,8 +486,54 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
434
486
  requestAnimationFrame(() => rescaleRef.current());
435
487
  return;
436
488
  }
437
- inner.style.transformOrigin = "bottom left";
489
+ // BOTTOM-ANCHOR the live terminal, same rule as the preview.
490
+ //
491
+ // A session's grid rarely divides evenly into a tile: measured on the
492
+ // real wall, a 40-row session rendered 475px tall inside a 460px box,
493
+ // so the last row — Claude's composer, the newest output — sat below
494
+ // the clip and was simply invisible. Going live didn't help (identical
495
+ // geometry) and only full screen "fixed" it, by refitting. Autofit
496
+ // can't save it either: a one-row overflow is well inside the claim
497
+ // tolerance, so no resize is ever asked for and the crop is permanent.
498
+ // Translate so the LAST row is flush with the box bottom; overflow now
499
+ // crops the oldest rows off the top, which is what scrollback is for.
500
+ // Transform the SCREEN, not `.xterm`. `.xterm` sets overflow:hidden on
501
+ // itself, so translating it slides an already-clipped window: the rows
502
+ // outside never appear no matter how far you pan (and a rect-based
503
+ // measurement is fooled, because getBoundingClientRect reports clipped
504
+ // geometry as if visible — which is how the first version of this
505
+ // "fixed" the bottom crop on paper while the pixels stayed hidden).
506
+ // Moving the screen inside that clip is what actually reveals rows.
438
507
  inner.style.transform = "";
508
+ screen.style.transformOrigin = "bottom left";
509
+ screen.style.transform = "";
510
+ const boxRect = inner.getBoundingClientRect(); // .xterm IS the clip
511
+ const screenRect = screen.getBoundingClientRect(); // reads after the reset above
512
+ const overflowX = Math.max(0, Math.round(screenRect.width - boxRect.width));
513
+ const overflowY = Math.max(0, Math.round(screenRect.height - boxRect.height));
514
+ // Auto anchor: last row flush with the box bottom, left edge shown.
515
+ const autoY = Math.round(boxRect.bottom - screenRect.bottom);
516
+ const pan = panRef.current;
517
+ // A pan is clamped to whatever is actually off-screen right now — the
518
+ // tile may have been resized since the user dragged.
519
+ const px = pan ? Math.max(-overflowX, Math.min(0, pan.x)) : 0;
520
+ const py = pan ? Math.max(autoY, Math.min(0, pan.y)) : autoY;
521
+ if (pan) panRef.current = { x: px, y: py };
522
+ if (px !== 0 || py !== 0) screen.style.transform = `translate(${px}px, ${py}px)`;
523
+ // Only publish on CHANGE: rescale runs from a ResizeObserver, and
524
+ // setting a fresh object every pass would re-render (and re-observe)
525
+ // in a loop.
526
+ const next = overflowX > 1 || overflowY > 1
527
+ ? { x: overflowX, y: overflowY, cols: term.cols, rows: term.rows }
528
+ : null;
529
+ const prev = offGridRef.current;
530
+ const same = (!next && !prev)
531
+ || (!!next && !!prev && next.x === prev.x && next.y === prev.y
532
+ && next.cols === prev.cols && next.rows === prev.rows);
533
+ if (!same) {
534
+ offGridRef.current = next;
535
+ setOffGrid(next);
536
+ }
439
537
  };
440
538
  rescaleRef.current = rescale;
441
539
  const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(rescale) : null;
@@ -463,8 +561,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
463
561
  }
464
562
  return false;
465
563
  }
564
+ // Shift+Enter → CSI 13;2u (newline in Claude's composer instead of
565
+ // submit). Claude parses CSI-u unconditionally but no longer
566
+ // advertises the kitty protocol at boot, so keyboardEnhanced is
567
+ // legitimately false — detection of Claude ITSELF is the gate, and it
568
+ // must survive a restore (see looksLikeClaude). A plain shell would
569
+ // render raw CSI-u as junk text, so the gate stays.
466
570
  if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.metaKey && !ev.altKey
467
- && (kbdEnhancedRef.current || claudeAppRef.current)) {
571
+ && (kbdEnhancedRef.current || looksLikeClaude())) {
468
572
  if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;2u");
469
573
  return false;
470
574
  }
@@ -479,12 +583,79 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
479
583
  return true;
480
584
  });
481
585
  const sub = term.onData((data) => sendInputRef.current?.(data));
586
+ // ⌥-drag pans an oversized terminal inside its tile. The modifier matters:
587
+ // a plain drag belongs to the session (text selection, or mouse reports
588
+ // to a tracking app like Claude), so panning must not steal it.
589
+ let panning: { sx: number; sy: number; ox: number; oy: number } | null = null;
590
+ const panBounds = () => {
591
+ const screen = box.querySelector(".xterm-screen") as HTMLElement | null;
592
+ const clip = box.querySelector(".xterm") as HTMLElement | null;
593
+ if (!screen || !clip) return null;
594
+ const boxRect = clip.getBoundingClientRect(); // same clip rescale uses
595
+ const scrRect = screen.getBoundingClientRect();
596
+ return {
597
+ overflowX: Math.max(0, scrRect.width - boxRect.width),
598
+ overflowY: Math.max(0, scrRect.height - boxRect.height),
599
+ autoY: Math.round(boxRect.bottom - scrRect.bottom)
600
+ };
601
+ };
602
+ const onPanDown = (e: PointerEvent) => {
603
+ if (!e.altKey || e.button !== 0) return;
604
+ const b = panBounds();
605
+ if (!b || (b.overflowX < 1 && b.overflowY < 1)) return;
606
+ const cur = panRef.current || { x: 0, y: b.autoY };
607
+ panRef.current = cur;
608
+ panning = { sx: e.clientX, sy: e.clientY, ox: cur.x, oy: cur.y };
609
+ try { box.setPointerCapture(e.pointerId); } catch { /* not captureable */ }
610
+ e.preventDefault();
611
+ e.stopPropagation();
612
+ };
613
+ const onPanMove = (e: PointerEvent) => {
614
+ if (!panning) return;
615
+ panRef.current = {
616
+ x: panning.ox + (e.clientX - panning.sx),
617
+ y: panning.oy + (e.clientY - panning.sy)
618
+ };
619
+ rescaleRef.current(); // clamps to the current overflow and repaints
620
+ e.preventDefault();
621
+ };
622
+ const endPan = (e: PointerEvent) => {
623
+ if (!panning) return;
624
+ panning = null;
625
+ try { box.releasePointerCapture(e.pointerId); } catch { /* already released */ }
626
+ };
627
+ // The natural gesture: a two-finger SIDEWAYS swipe pans a terminal that
628
+ // is wider than its tile. Horizontal wheel deltas are otherwise unused
629
+ // here (vertical belongs to scrolling the session), so this costs no
630
+ // other interaction and needs no modifier. Capture + non-passive because
631
+ // xterm would otherwise consume the event on a tracking app.
632
+ const onPanWheel = (e: WheelEvent) => {
633
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; // vertical = scroll
634
+ const b = panBounds();
635
+ if (!b || b.overflowX < 1) return;
636
+ const cur = panRef.current || { x: 0, y: b.autoY };
637
+ panRef.current = { x: cur.x - e.deltaX, y: cur.y };
638
+ rescaleRef.current();
639
+ e.preventDefault();
640
+ e.stopPropagation();
641
+ };
642
+ box.addEventListener("wheel", onPanWheel, { capture: true, passive: false });
643
+ box.addEventListener("pointerdown", onPanDown, true);
644
+ box.addEventListener("pointermove", onPanMove, true);
645
+ box.addEventListener("pointerup", endPan, true);
646
+ box.addEventListener("pointercancel", endPan, true);
647
+
482
648
  const detachFlywheel = attachScrollFlywheel(box, () => termRef.current as never, {
483
649
  linesPerNotch: 4,
484
650
  lineHeightPx: () => Math.max(1, ((term as never as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { height?: number } } } } } })._core
485
651
  ?._renderService?.dimensions?.css?.cell?.height) || PREVIEW_BASE_FS * 1.3)
486
652
  });
487
653
  return () => {
654
+ box.removeEventListener("wheel", onPanWheel, { capture: true } as EventListenerOptions);
655
+ box.removeEventListener("pointerdown", onPanDown, true);
656
+ box.removeEventListener("pointermove", onPanMove, true);
657
+ box.removeEventListener("pointerup", endPan, true);
658
+ box.removeEventListener("pointercancel", endPan, true);
488
659
  detachFlywheel();
489
660
  ro?.disconnect();
490
661
  sub.dispose();
@@ -857,6 +1028,28 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
857
1028
  {viewers.some((v) => /agent|hopa|\(pane\)/i.test(v.name)) && <b className="viewer-agent">AI</b>}
858
1029
  </span>
859
1030
  )}
1031
+ {offGrid && (
1032
+ // Say WHY the tile is cropped. Without this the session just
1033
+ // looks broken; with it, the size is visibly someone else's
1034
+ // choice, and both remedies are one gesture away.
1035
+ <button
1036
+ type="button"
1037
+ className="switcher-tile-offgrid"
1038
+ title={
1039
+ `This session's terminal is ${offGrid.cols}×${offGrid.rows}, larger than the tile`
1040
+ + (driver ? ` — ${driver.name} is using it right now` : viewers.length ? " — another client holds the size" : "")
1041
+ + ".\n⌥-drag to pan. Click to resize the session to fit this tile."
1042
+ }
1043
+ onClick={(e) => {
1044
+ e.stopPropagation();
1045
+ panRef.current = null; // back to the automatic anchor
1046
+ claimRef.current();
1047
+ }}
1048
+ >
1049
+ <span className="offgrid-glyph" aria-hidden="true">⤢</span>
1050
+ {offGrid.cols}×{offGrid.rows}
1051
+ </button>
1052
+ )}
860
1053
  {find && (
861
1054
  <span className="switcher-tile-find" onClick={(e) => e.stopPropagation()}>
862
1055
  <input
@@ -1341,6 +1534,31 @@ export const SessionSwitcher = ({
1341
1534
  flatNav.forEach((s, i) => m.set(sessionKey(s), i));
1342
1535
  return m;
1343
1536
  }, [flatNav]);
1537
+ // Opening the wall focuses the session the app is ON. A deep link like
1538
+ // /s/<name>/?view=wall names a session, so landing on the wall with
1539
+ // nothing focused (and the keyboard sitting on the FIRST card) loses the
1540
+ // one thing the URL asked for. The old name works too — the room id is
1541
+ // the internal name, so a renamed session still resolves. This is not an
1542
+ // "accidental" focus: arriving here is the user's explicit act, and it
1543
+ // focuses in place rather than entering full screen.
1544
+ useEffect(() => {
1545
+ if (!open || focusedKey || !currentRoom) return;
1546
+ const current = flatNav.find((s) => (s.internalName || s.name) === currentRoom || s.name === currentRoom);
1547
+ if (!current) return;
1548
+ const key = sessionKey(current);
1549
+ setFocusedKey(key);
1550
+ const idx = navIndexByKey.get(key);
1551
+ if (typeof idx === "number") {
1552
+ setKbdIndex(idx);
1553
+ // ...and bring it into view. A deep link can name a session sitting
1554
+ // far down a long wall; focusing a card the user cannot see is the
1555
+ // same as not focusing it. Deferred a frame so the card exists.
1556
+ requestAnimationFrame(() => {
1557
+ document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
1558
+ });
1559
+ }
1560
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1561
+ }, [open, currentRoom, flatNav, navIndexByKey]);
1344
1562
  // Latest visual order, for reorderManual (defined earlier in the component).
1345
1563
  // Every session in the current scope, in manual order — the canonical list
1346
1564
  // reordering works against, regardless of what the filter is showing.
@@ -3482,15 +3482,25 @@ button.primary {
3482
3482
  overflow: hidden;
3483
3483
  }
3484
3484
  .switcher-preview-screen {
3485
+ /* BOTTOM-anchored. Anchored at the top, a screen even slightly taller than
3486
+ its tile had its LAST rows clipped — and the last rows are the composer
3487
+ and the newest output, i.e. the entire reason to look at the tile. The
3488
+ autofit claim can't rescue it either: a one-row overflow is inside the
3489
+ ~15% tolerance, so no resize is ever requested and the crop is
3490
+ permanent. Overflow now eats the OLDEST rows off the top instead. */
3485
3491
  position: absolute;
3486
- top: 0;
3492
+ bottom: 0;
3487
3493
  left: 0;
3488
- transform-origin: top left;
3494
+ transform-origin: bottom left;
3489
3495
  font-family: var(--font-terminal);
3490
3496
  }
3491
3497
  .switcher-preview-screen .switcher-preview-row {
3492
- height: 1.3em;
3493
- line-height: 1.3em;
3498
+ /* --tile-row-h is xterm's OWN row height for this font (derived from the
3499
+ font's natural line box, not fontSize — see tileRowHeight). Matching it
3500
+ is what keeps preview rows on the same baselines the live terminal will
3501
+ use; the 1.3em fallback only applies before the measurement lands. */
3502
+ height: var(--tile-row-h, 1.3em);
3503
+ line-height: var(--tile-row-h, 1.3em);
3494
3504
  }
3495
3505
 
3496
3506
 
@@ -3586,6 +3596,31 @@ button.primary {
3586
3596
  white-space: nowrap;
3587
3597
  }
3588
3598
 
3599
+ /* Live-tile: the session's grid is bigger than this tile, so the view is
3600
+ cropped. Same pill vocabulary as the presence chrome — this is status,
3601
+ not an error, because someone else legitimately owns the size. */
3602
+ .switcher-live-chrome .switcher-tile-offgrid {
3603
+ display: inline-flex;
3604
+ align-items: center;
3605
+ gap: 3px;
3606
+ font-family: var(--font-mono);
3607
+ font-size: 10.5px;
3608
+ color: var(--ink-muted);
3609
+ padding: 0 5px;
3610
+ height: 16px;
3611
+ border: 1px solid color-mix(in srgb, var(--ink-muted) 30%, transparent);
3612
+ border-radius: 4px;
3613
+ background: color-mix(in srgb, var(--surface) 80%, transparent);
3614
+ cursor: pointer;
3615
+ }
3616
+ .switcher-live-chrome .switcher-tile-offgrid:hover {
3617
+ color: var(--ink);
3618
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
3619
+ }
3620
+ .switcher-live-chrome .offgrid-glyph {
3621
+ opacity: 0.75;
3622
+ }
3623
+
3589
3624
  /* Live-tile: who else is here, and who is driving. */
3590
3625
  .switcher-live-chrome .switcher-live-viewers {
3591
3626
  display: inline-flex;
@@ -24,10 +24,23 @@ type TerminalLike = {
24
24
  modes?: { mouseTrackingMode?: string };
25
25
  };
26
26
 
27
- // Momentum (and scrollback UI generally) is only honest when the TERMINAL
28
- // owns scrolling. With mouse tracking on, xterm forwards wheel events to the
29
- // app (claude scrolls its own transcript) — synthetic glide would scroll the
30
- // terminal viewport into torn scrollback underneath the app.
27
+ /** Lines this wheel event should move, mirroring xterm's own arithmetic. */
28
+ const wheelLines = (e: WheelEvent, opts: { linesPerNotch: number; lineHeightPx: () => number }) =>
29
+ e.deltaMode === 1
30
+ ? e.deltaY * opts.linesPerNotch
31
+ : (e.deltaY / Math.max(1, opts.lineHeightPx())) * opts.linesPerNotch;
32
+
33
+ // WHO OWNS THE WHEEL. Momentum (and scrollback UI generally) is only honest
34
+ // when the TERMINAL owns scrolling: the normal buffer, with the app not
35
+ // tracking the mouse. Otherwise the app owns the wheel and we keep our hands
36
+ // off — xterm forwards the notch and the app scrolls its own view.
37
+ //
38
+ // Measured on the live fleet (2026-07-31): every Claude Code session runs
39
+ // ALT-SCREEN (?1049h) with mouse tracking on, so Claude is always in the
40
+ // app-owns-it branch — its transcript is its own to scroll, and there is no
41
+ // local scrollback behind it to move. Do not "improve" this by scrolling
42
+ // locally when tracking is on: a tracking app asked for wheel events, and
43
+ // stealing them breaks the very apps that requested them.
31
44
  const terminalOwnsScrolling = (term: TerminalLike) =>
32
45
  term.buffer.active.type === "normal" && (term.modes?.mouseTrackingMode ?? "none") === "none";
33
46
 
@@ -61,13 +74,14 @@ export const attachScrollFlywheel = (
61
74
  ): (() => void) => {
62
75
  let vel = 0; // lines/second, signed
63
76
  let carry = 0; // fractional-line remainder between frames
77
+ let synthetic = false; // true while WE re-dispatch a wheel event
64
78
  let burst = 0; // wheel events in the current spin
65
79
  let lastWheelAt = 0;
66
80
  let lastFrameAt = 0;
67
81
  let raf = 0;
68
82
  let idleTimer = 0;
69
83
 
70
- const stop = () => {
84
+ const stopLocal = () => {
71
85
  if (raf) cancelAnimationFrame(raf);
72
86
  if (idleTimer) window.clearTimeout(idleTimer);
73
87
  raf = 0;
@@ -77,6 +91,102 @@ export const attachScrollFlywheel = (
77
91
  burst = 0;
78
92
  };
79
93
 
94
+ // ---- App-owned scrolling (alt screen / mouse tracking) -------------------
95
+ // The app scrolls itself, one step per wheel event. We re-dispatch wheel
96
+ // events to give that stream a natural shape: a notch is worth a few steps,
97
+ // and a spin coasts to a stop instead of halting dead. Re-dispatching (vs
98
+ // encoding mouse reports ourselves) keeps xterm the single owner of the
99
+ // wire format — whatever it sends for a real notch is exactly what it sends
100
+ // for ours.
101
+ const APP_STEPS_PER_NOTCH = 3;
102
+ const APP_MAX_RATE = 90; // steps/sec — fast, still ordered
103
+ let appVel = 0;
104
+ let appCarry = 0;
105
+ let appRaf = 0;
106
+ let appIdle = 0;
107
+ let appBurst = 0;
108
+ let appLastAt = 0;
109
+ let appTarget: EventTarget | null = null;
110
+ let appProto: { deltaY: number; clientX: number; clientY: number } | null = null;
111
+
112
+ const stopApp = () => {
113
+ if (appRaf) cancelAnimationFrame(appRaf);
114
+ if (appIdle) window.clearTimeout(appIdle);
115
+ appRaf = 0;
116
+ appIdle = 0;
117
+ appVel = 0;
118
+ appCarry = 0;
119
+ appBurst = 0;
120
+ };
121
+
122
+ const stop = () => { stopLocal(); stopApp(); };
123
+
124
+ const dispatchStep = () => {
125
+ if (!appTarget || !appProto) return;
126
+ synthetic = true;
127
+ try {
128
+ appTarget.dispatchEvent(new WheelEvent("wheel", {
129
+ deltaY: appProto.deltaY,
130
+ deltaMode: 0,
131
+ clientX: appProto.clientX,
132
+ clientY: appProto.clientY,
133
+ bubbles: true,
134
+ cancelable: true
135
+ }));
136
+ } catch {
137
+ /* target detached mid-glide */
138
+ } finally {
139
+ synthetic = false;
140
+ }
141
+ };
142
+
143
+ const appGlide = (t: number) => {
144
+ appRaf = 0;
145
+ const term = getTerm();
146
+ // Mode flipped (app exited, screen switched) or run out of speed.
147
+ if (!term || terminalOwnsScrolling(term) || Math.abs(appVel) < 4) return stopApp();
148
+ const dt = lastFrameAt ? Math.min(0.05, (t - lastFrameAt) / 1000) : 0.016;
149
+ lastFrameAt = t;
150
+ appCarry += Math.abs(appVel) * dt;
151
+ let steps = Math.trunc(appCarry);
152
+ appCarry -= steps;
153
+ while (steps-- > 0) dispatchStep();
154
+ appVel *= Math.exp(-dt / 0.35);
155
+ appRaf = requestAnimationFrame(appGlide);
156
+ };
157
+
158
+ const appWheel = (e: WheelEvent) => {
159
+ appTarget = e.target;
160
+ appProto = { deltaY: e.deltaY, clientX: e.clientX, clientY: e.clientY };
161
+ if (appRaf) { cancelAnimationFrame(appRaf); appRaf = 0; }
162
+
163
+ const now = e.timeStamp || performance.now();
164
+ const gap = now - appLastAt;
165
+ appLastAt = now;
166
+ if (gap > 250) appBurst = 0;
167
+ appBurst += 1;
168
+
169
+ // One notch already produced one step (the real event xterm just
170
+ // forwarded); add the rest so a notch moves like a notch.
171
+ for (let i = 1; i < APP_STEPS_PER_NOTCH; i++) dispatchStep();
172
+
173
+ const rate = APP_STEPS_PER_NOTCH / (Math.min(Math.max(gap, 30), 200) / 1000);
174
+ const signed = Math.sign(e.deltaY) * rate;
175
+ appVel = Math.sign(signed) === Math.sign(appVel) ? 0.6 * signed + 0.4 * appVel : signed;
176
+ appVel = Math.max(-APP_MAX_RATE, Math.min(APP_MAX_RATE, appVel));
177
+
178
+ if (appIdle) window.clearTimeout(appIdle);
179
+ appIdle = window.setTimeout(() => {
180
+ appIdle = 0;
181
+ if (appBurst >= 3 && Math.abs(appVel) >= 4) {
182
+ lastFrameAt = 0;
183
+ appRaf = requestAnimationFrame(appGlide);
184
+ } else {
185
+ stopApp();
186
+ }
187
+ }, 80);
188
+ };
189
+
80
190
  const glide = (t: number) => {
81
191
  raf = 0;
82
192
  const term = getTerm();
@@ -96,11 +206,24 @@ export const attachScrollFlywheel = (
96
206
  };
97
207
 
98
208
  const onWheel = (e: WheelEvent) => {
209
+ if (synthetic) return; // our own re-dispatch — never feed it back in
99
210
  const term = getTerm();
100
211
  if (!term) return;
101
- // Alt screen or trackpad: no synthetic inertia, and kill any active glide
102
- // (mode may have flipped mid-glide).
103
- if (!terminalOwnsScrolling(term) || !isDiscreteWheel(e)) return stop();
212
+ // App owns the wheel (alt screen, or mouse tracking on — i.e. every
213
+ // Claude session). We must not scroll locally, but a terminal app moves
214
+ // one step per wheel event, so a discrete wheel crawls a row at a time.
215
+ // Give it the SAME feel a native scroll has: amplify each notch into a
216
+ // few events, then coast with a decaying stream after the spin. The app
217
+ // still does all the scrolling; we only shape the event rate.
218
+ if (!terminalOwnsScrolling(term)) {
219
+ stopLocal();
220
+ if (isDiscreteWheel(e)) appWheel(e);
221
+ else stopApp(); // trackpad: the OS already sends a dense glide
222
+ return;
223
+ }
224
+ stopApp();
225
+ // Trackpad: native inertia already glides; never double it.
226
+ if (!isDiscreteWheel(e)) return stop();
104
227
  if (raf) { cancelAnimationFrame(raf); raf = 0; } // wheel resumes: rebuild, don't glide yet
105
228
 
106
229
  const now = e.timeStamp || performance.now();
@@ -109,10 +232,7 @@ export const attachScrollFlywheel = (
109
232
  if (gap > 250) burst = 0;
110
233
  burst += 1;
111
234
 
112
- const notchLines =
113
- e.deltaMode === 1
114
- ? e.deltaY * opts.linesPerNotch
115
- : (e.deltaY / Math.max(1, opts.lineHeightPx())) * opts.linesPerNotch;
235
+ const notchLines = wheelLines(e, opts);
116
236
  const rate = notchLines / (Math.min(Math.max(gap, 30), 200) / 1000);
117
237
  // Same direction: blend toward the new rate. Direction flip: hard reset.
118
238
  vel = Math.sign(rate) === Math.sign(vel) ? 0.6 * rate + 0.4 * vel : rate;
@@ -133,12 +253,18 @@ export const attachScrollFlywheel = (
133
253
  };
134
254
 
135
255
  const kill = () => stop();
136
- el.addEventListener("wheel", onWheel, { passive: true });
256
+ // CAPTURE phase, but strictly observational (passive: we never
257
+ // preventDefault). Capture is REQUIRED: when the app is tracking the
258
+ // mouse, xterm's own handler calls stopPropagation() on the way out, so a
259
+ // bubble-phase listener on this ancestor never runs at all — which is why
260
+ // the app-scroll shaping below silently did nothing. Observing first lets
261
+ // xterm do exactly what it always did, and lets us add steps around it.
262
+ el.addEventListener("wheel", onWheel, { capture: true, passive: true });
137
263
  el.addEventListener("mousedown", kill, true);
138
264
  window.addEventListener("keydown", kill, true);
139
265
  return () => {
140
266
  stop();
141
- el.removeEventListener("wheel", onWheel);
267
+ el.removeEventListener("wheel", onWheel, { capture: true } as EventListenerOptions);
142
268
  el.removeEventListener("mousedown", kill, true);
143
269
  window.removeEventListener("keydown", kill, true);
144
270
  };
@@ -152,6 +152,19 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
152
152
  return { handleKey, dispose, isActive: () => active };
153
153
  };
154
154
 
155
+ /**
156
+ * Is this surface running Claude Code? Used for KEY ENCODING (Shift+Enter's
157
+ * CSI-u) and voice eligibility, so it must stay true for RESTORED sessions:
158
+ * an argv-launched restore (`shell -lc "claude …; exec shell -l"`) reports
159
+ * the wrapper shell as the foreground process forever, so a process-only
160
+ * check goes false on every session that came back through `hop restore`.
161
+ * The terminal's own chrome is the honest second witness.
162
+ */
163
+ export const isClaudeSurface = (
164
+ foregroundIsClaude: boolean,
165
+ terminal: Parameters<typeof bufferLooksLikeClaude>[0] | null | undefined
166
+ ): boolean => foregroundIsClaude || (!!terminal && bufferLooksLikeClaude(terminal));
167
+
155
168
  /** Claude chrome scan over a terminal's visible rows — shared eligibility. */
156
169
  export const bufferLooksLikeClaude = (t: {
157
170
  rows: number;
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isClaudeSurface } from "../src/utils/voiceHold";
3
+
4
+ // A fake xterm buffer showing the given visible rows.
5
+ const term = (rows: string[]) => ({
6
+ rows: rows.length,
7
+ buffer: {
8
+ active: {
9
+ baseY: 0,
10
+ getLine: (row: number) =>
11
+ rows[row] === undefined ? undefined : { translateToString: () => rows[row] }
12
+ }
13
+ }
14
+ });
15
+
16
+ const CLAUDE_SCREEN = [
17
+ "> summarize this file",
18
+ "",
19
+ "⏵⏵ bypass permissions on (shift+tab to cycle)"
20
+ ];
21
+ const SHELL_SCREEN = ["jianzhou@MED-GEN-ML-15 hop2 % ls", "hop hay scripts", "jianzhou@MED-GEN-ML-15 hop2 %"];
22
+
23
+ describe("isClaudeSurface", () => {
24
+ it("trusts the foreground process when it names Claude", () => {
25
+ expect(isClaudeSurface(true, null)).toBe(true);
26
+ expect(isClaudeSurface(true, term(SHELL_SCREEN) as never)).toBe(true);
27
+ });
28
+
29
+ // THE regression: `hop restore` launches Claude as the room's argv
30
+ // (`shell -lc "claude …; exec shell -l"`), so the room reports the WRAPPER
31
+ // SHELL as its foreground process for the rest of the session's life. A
32
+ // process-only check therefore reads false on every restored session —
33
+ // which silently disabled Shift+Enter (CSI 13;2u) in wall tiles, turning
34
+ // every newline attempt into a submit.
35
+ it("still recognizes Claude when the process check is blind (restored session)", () => {
36
+ expect(isClaudeSurface(false, term(CLAUDE_SCREEN) as never)).toBe(true);
37
+ });
38
+
39
+ it("stays false for a plain shell, where raw CSI-u would render as junk", () => {
40
+ expect(isClaudeSurface(false, term(SHELL_SCREEN) as never)).toBe(false);
41
+ expect(isClaudeSurface(false, null)).toBe(false);
42
+ expect(isClaudeSurface(false, undefined)).toBe(false);
43
+ });
44
+ });