hop2 0.11.34 → 0.11.36

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.
@@ -8,6 +8,40 @@ import type { PtyFactory } from "./pty";
8
8
  import { createScreenGrid, loadScreenGridDeps, screenGridsAvailable } from "./screenGrid";
9
9
  import type { ScreenGrid } from "./screenGrid";
10
10
 
11
+ /**
12
+ * Darwin `lsof` probes are rate-limited process-wide, not per room.
13
+ *
14
+ * Each room polls its own cwd on its own timer, so the cost of the fallback
15
+ * scales with the number of live rooms — and `lsof` on macOS is not a cheap
16
+ * syscall but a process that stats every mount, blocking uninterruptibly when
17
+ * any of them is slow. At ~150 rooms the 15s timers align into bursts of ~150
18
+ * concurrent `lsof`s, each holding a 5s timeout; every one of them counts
19
+ * toward the load average. That is how this host drove the machine to a
20
+ * 1-minute load of 367 across 18 cores on 2026-09-01, starving its own event
21
+ * loop until the watchdog restarted it and killed every session inside.
22
+ *
23
+ * A small global ceiling keeps the fallback a fallback. Probes that arrive
24
+ * while the gate is full are DROPPED, not queued: this is a best-effort poll
25
+ * that repeats every 15s anyway, and a queue would just rebuild the burst.
26
+ */
27
+ const MAX_CONCURRENT_CWD_PROBES = 4;
28
+ let activeCwdProbes = 0;
29
+
30
+ function runGatedCwdProbe(pid: number, onCwd: (cwdPath: string) => void) {
31
+ if (activeCwdProbes >= MAX_CONCURRENT_CWD_PROBES) return;
32
+ activeCwdProbes++;
33
+ execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000 }, (err, stdout) => {
34
+ activeCwdProbes--;
35
+ if (err || !stdout) return;
36
+ for (const line of stdout.split("\n")) {
37
+ if (line.startsWith("n/")) {
38
+ onCwd(line.slice(1));
39
+ return;
40
+ }
41
+ }
42
+ });
43
+ }
44
+
11
45
  export type SocketAdapter = {
12
46
  send: (data: string) => void;
13
47
  onMessage: (handler: (data: string) => void) => void;
@@ -787,6 +821,12 @@ export class Room extends EventEmitter {
787
821
  return;
788
822
  }
789
823
 
824
+ // The shell reports its own cwd, so the lsof fallback has nothing left to
825
+ // add for this room — retire its timer. Most rooms run a shell that emits
826
+ // OSC 7, so this is what keeps the global probe budget for the few that
827
+ // genuinely need it.
828
+ this.stopCwdPolling();
829
+
790
830
  if (cwdPath && cwdPath !== this.liveCwd) {
791
831
  if (DEBUG_STATE) {
792
832
  console.log(`[hay] room=${this.id} cwd=${cwdPath}`);
@@ -830,15 +870,7 @@ export class Room extends EventEmitter {
830
870
  this.foregroundProcess = fg.replace(/^-/, ""); // strip login-shell "-zsh" dash
831
871
  }
832
872
  if (process.platform === "darwin") {
833
- execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000 }, (err, stdout) => {
834
- if (err || !stdout) return;
835
- for (const line of stdout.split("\n")) {
836
- if (line.startsWith("n/")) {
837
- updateCwd(line.slice(1));
838
- break;
839
- }
840
- }
841
- });
873
+ runGatedCwdProbe(pid, updateCwd);
842
874
  } else if (process.platform === "linux") {
843
875
  readlink(`/proc/${pid}/cwd`, (err, target) => {
844
876
  if (err || !target) return;
@@ -847,9 +879,13 @@ export class Room extends EventEmitter {
847
879
  }
848
880
  };
849
881
 
850
- this.cwdPollTimer = setInterval(poll, intervalMs);
882
+ // Jitter both the phase and the period: identical timers started together
883
+ // (a `hop restore` brings up dozens of rooms at once) stay in lockstep for
884
+ // the life of the host, turning a per-room poll into a synchronized burst.
885
+ const jitteredInterval = intervalMs + Math.floor(Math.random() * intervalMs);
886
+ this.cwdPollTimer = setInterval(poll, jitteredInterval);
851
887
  // Initial poll with a generous delay to not interfere with startup
852
- setTimeout(poll, 3000);
888
+ setTimeout(poll, 3000 + Math.floor(Math.random() * intervalMs));
853
889
  }
854
890
 
855
891
  stopCwdPolling() {
@@ -10,7 +10,7 @@ import {
10
10
  type ClientMessage
11
11
  } from "hay-shared";
12
12
  import { activityLabel, sortPresence } from "./utils/presence";
13
- import { createOptimisticEcho } from "./utils/optimisticEcho";
13
+ import { createOptimisticEcho, hasActiveOtherTypist } from "./utils/optimisticEcho";
14
14
  import { attachScrollFlywheel } from "./utils/scrollFlywheel";
15
15
  import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
16
16
  import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
@@ -231,6 +231,10 @@ const paneTreeHasPrimary = (n: PaneNode): boolean =>
231
231
  n.kind === "leaf" ? n.session === null : paneTreeHasPrimary(n.a) || paneTreeHasPrimary(n.b);
232
232
 
233
233
  const LATENCY_COMP = true;
234
+ // A WebSocket handshake normally completes in well under a second. One still
235
+ // CONNECTING after this long has hung (a stalled tunnel upgrade) and must be
236
+ // aborted and retried — it will never resolve itself.
237
+ const STUCK_CONNECTING_MS = 8000;
234
238
  const AUTO_FIT_ON_TYPE = true;
235
239
 
236
240
  type SessionSwitchMode = "page" | "instant";
@@ -454,6 +458,14 @@ const App = () => {
454
458
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
455
459
  const [loadingSessions, setLoadingSessions] = useState(false);
456
460
  const [sessionsError, setSessionsError] = useState(false);
461
+ // The desktop twin of the iOS swipe filmstrip: an on-demand HUD showing the
462
+ // recency ring centred on the current session, target lit, that appears
463
+ // while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
464
+ // ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
465
+ // the iOS swipe order). Ref mirror for the keyup/wheel listeners.
466
+ const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number } | null>(null);
467
+ const switchHudRef = useRef<{ ring: SessionInfo[]; index: number } | null>(null);
468
+ switchHudRef.current = switchHud;
457
469
  // Server-owned folders (Manual mode). Kept in App because the sessions
458
470
  // fetch already carries them.
459
471
  const [folders, setFolders] = useState<Array<{ id: string; name: string }>>([]);
@@ -602,6 +614,7 @@ const App = () => {
602
614
  const viewModeRef = useRef(viewMode);
603
615
  const reconnectAttemptRef = useRef(0);
604
616
  const reconnectTimerRef = useRef<number | null>(null);
617
+ const connectStartedAtRef = useRef<number>(0);
605
618
  const shouldReconnectRef = useRef(true);
606
619
  const connectNonceRef = useRef(0);
607
620
  const userScrolledUpRef = useRef(false);
@@ -648,6 +661,18 @@ const App = () => {
648
661
  const pendingInputRef = useRef<Array<{ room: string; data: string; at: number }>>([]);
649
662
  // Presence re-render throttle (see the "presence" message handler).
650
663
  const presenceThrottleRef = useRef<number | null>(null);
664
+ // Per-peer "last observed typing" — refreshed on every presence frame that
665
+ // shows a peer typing. A live typist keeps advancing it (presence fires per
666
+ // keystroke); a ghost that dropped mid-type stops, so its flag ages out
667
+ // instead of pinning local echo off forever. See hasActiveOtherTypist.
668
+ const typingLastSeenRef = useRef<Map<string, number>>(new Map());
669
+ // Fresh mirrors of the echo-gating state, read at KEYSTROKE time (not from a
670
+ // possibly-stale render), so a silent ghost never keeps echo disabled once
671
+ // its typing flag has gone stale — no re-render is needed to recover.
672
+ const presenceRef = useRef<PresenceClient[]>([]);
673
+ const collabModeRef = useRef(true);
674
+ const controllerIdRef = useRef<string | null>(null);
675
+ const clientIdRef = useRef<string | null>(null);
651
676
  const presencePendingRef = useRef<PresenceClient[] | null>(null);
652
677
  const activeSessionRoomRef = useRef<string | null>(null);
653
678
  // Set each render once switchSession exists (defined later); the keyboard
@@ -1081,10 +1106,27 @@ const App = () => {
1081
1106
  // now (presence carries the flag), and drops the instant one starts. A
1082
1107
  // sub-second overlap before presence propagates is absorbed by the
1083
1108
  // reconciler's guards (complex-chunk passthrough, two-strike clear).
1084
- const othersTyping = presence.some((c) => c.id !== clientId && c.typing);
1085
- const optimisticActive =
1086
- LATENCY_COMP && status === "connected" && !othersTyping
1087
- && (collabMode ? true : controllerId === clientId);
1109
+ // Keep the keystroke-time mirrors current every render.
1110
+ presenceRef.current = presence;
1111
+ collabModeRef.current = collabMode;
1112
+ controllerIdRef.current = controllerId;
1113
+ // The single source of truth for "should local echo be on", evaluated from
1114
+ // refs so it can be recomputed at keystroke time. A peer only suppresses
1115
+ // echo if we've seen them typing RECENTLY (ghosts age out).
1116
+ const echoEnabledNow = () =>
1117
+ LATENCY_COMP && statusForChromeRef.current === "connected"
1118
+ // NOT on the alternate screen. A full-screen TUI (Claude Code's composer,
1119
+ // vim, less) repaints on every keystroke, and that redraw IS the echo —
1120
+ // an optimistic char painted first then overwritten by the redraw is a
1121
+ // visible flicker ("rendered wrong, then changes"), for no latency win the
1122
+ // app's own fast repaint doesn't already give. Optimistic echo belongs to
1123
+ // the SHELL, where the terminal echoes char-by-char with no redraw to
1124
+ // fight. (Reconcile's TUI guard still protects any in-flight echo when the
1125
+ // app flips to alt-screen mid-keystroke.)
1126
+ && !remoteAltScreenRef.current
1127
+ && !hasActiveOtherTypist(presenceRef.current, clientIdRef.current, typingLastSeenRef.current, Date.now())
1128
+ && (collabModeRef.current ? true : controllerIdRef.current === clientIdRef.current);
1129
+ const optimisticActive = echoEnabledNow();
1088
1130
 
1089
1131
  useEffect(() => {
1090
1132
  if (optimisticPrevRef.current && !optimisticActive) {
@@ -1241,7 +1283,7 @@ const App = () => {
1241
1283
  }
1242
1284
  return;
1243
1285
  }
1244
- const echoed = optimisticEchoRef.current.onInput(sanitized, optimisticActive);
1286
+ const echoed = optimisticEchoRef.current.onInput(sanitized, echoEnabledNow());
1245
1287
  if (echoed) {
1246
1288
  writeToTerminal(echoed);
1247
1289
  }
@@ -1372,7 +1414,6 @@ const App = () => {
1372
1414
  // clientId, so "is this size ours" is an identity check, never a size
1373
1415
  // comparison (comparing sizes is how a follower gets fooled into thinking
1374
1416
  // an adopted grid is its own).
1375
- const clientIdRef = useRef<string | null>(null);
1376
1417
  // Who owns the shared size right now, per the latest active_size.
1377
1418
  const activeOwnerRef = useRef<string | null>(null);
1378
1419
  // Non-null while a PEER owns the size in fit mode: the local terminal is
@@ -1653,6 +1694,10 @@ const App = () => {
1653
1694
 
1654
1695
  const ws = new WebSocket(url);
1655
1696
  wsRef.current = ws;
1697
+ // When this socket began CONNECTING — the watchdog uses it to catch a
1698
+ // handshake that hangs (accepted TCP, WS upgrade never completes through
1699
+ // the tunnel): it fires no open/close/error, so nothing else can notice.
1700
+ connectStartedAtRef.current = Date.now();
1656
1701
 
1657
1702
  ws.addEventListener("open", () => {
1658
1703
  if (connectionNonce !== connectNonceRef.current || activeSessionRoomRef.current !== targetRoom) {
@@ -1711,6 +1756,19 @@ const App = () => {
1711
1756
  // indicator) — re-rendering the whole app tree per remote key is
1712
1757
  // wasted main-thread time while output streams. Leading update for
1713
1758
  // immediacy, then at most one trailing update per 300ms window.
1759
+ // Refresh each typing peer's "last seen" from the RAW frame (before
1760
+ // the render throttle) so a live typist never ages out; a ghost that
1761
+ // stopped sending does. Prune peers no longer present.
1762
+ {
1763
+ const now = Date.now();
1764
+ const seen = typingLastSeenRef.current;
1765
+ const ids = new Set<string>();
1766
+ for (const c of message.clients) {
1767
+ ids.add(c.id);
1768
+ if (c.typing) seen.set(c.id, now);
1769
+ }
1770
+ for (const id of [...seen.keys()]) if (!ids.has(id)) seen.delete(id);
1771
+ }
1714
1772
  if (presenceThrottleRef.current === null) {
1715
1773
  setPresence(message.clients);
1716
1774
  presenceThrottleRef.current = window.setTimeout(() => {
@@ -1945,6 +2003,39 @@ const App = () => {
1945
2003
  // eslint-disable-next-line react-hooks/exhaustive-deps
1946
2004
  }, [session, terminalReady, reconnectToken]);
1947
2005
 
2006
+ // Reconnect watchdog — the guarantee that a live session never stays
2007
+ // stranded in a non-connected state. scheduleReconnect's loop bails
2008
+ // (correctly) in a few edge cases: a room-ref mismatch when a switch was
2009
+ // gated behind the open wall, or a retry timer cleared by a superseding
2010
+ // connect that then no-op'd. Individually right, together they can leave
2011
+ // the socket dead with nothing driving a retry — the "stuck reconnecting
2012
+ // until you refresh" symptom. This notices a session that SHOULD be up but
2013
+ // has no live/connecting socket and no pending retry, and kicks a fresh
2014
+ // connect through the normal effect (reconnectToken) — a refresh's recovery
2015
+ // without the refresh. It NEVER fires while a socket is open/connecting or a
2016
+ // retry is already scheduled, so it cannot cause a connect storm.
2017
+ useEffect(() => {
2018
+ if (!session || !terminalReady) return;
2019
+ const id = window.setInterval(() => {
2020
+ if (switcherOpenRef.current) return; // the wall owns the socket while it's up
2021
+ if (!shouldReconnectRef.current) return; // session ended / leaving on purpose
2022
+ const st = statusForChromeRef.current;
2023
+ if (st === "connected" || st === "ended") return;
2024
+ const ws = wsRef.current;
2025
+ if (ws && ws.readyState === WebSocket.OPEN) return; // healthy, leave it
2026
+ // A socket wedged in CONNECTING past the deadline is a hung upgrade —
2027
+ // it will never fire open/close/error on its own. Abort it so a fresh
2028
+ // connect can run; a handshake that hasn't hung yet is still given time.
2029
+ if (ws && ws.readyState === WebSocket.CONNECTING) {
2030
+ if (Date.now() - connectStartedAtRef.current < STUCK_CONNECTING_MS) return;
2031
+ try { ws.close(); } catch { /* aborting the hung upgrade */ }
2032
+ }
2033
+ if (reconnectTimerRef.current !== null) return; // a retry is already pending
2034
+ setReconnectToken((value) => value + 1); // stranded → drive a fresh connect
2035
+ }, 2500);
2036
+ return () => window.clearInterval(id);
2037
+ }, [session, terminalReady]);
2038
+
1948
2039
  useEffect(() => {
1949
2040
  if (!session || !containerRef.current || termRef.current) {
1950
2041
  return;
@@ -3706,19 +3797,43 @@ const App = () => {
3706
3797
  // Desktop keyboard layer — capture phase so shortcuts win over the focused
3707
3798
  // terminal. ⌘-based (Ctrl+Shift elsewhere) to stay clear of readline/TUI
3708
3799
  // keys, which the terminal must keep receiving untouched.
3709
- // ⌘K session palette · ⌘E last session (toggles) · ⌘J/⌘⇧J cycle by name ·
3800
+ // ⌘K session palette · ⌘E last session (toggles) · ⌘J/⌘L cycle left/right ·
3710
3801
  // ⌘, settings · ⌘+/−/0 font size · ⌘/ shortcut help · Esc closes drawer/help
3711
3802
  useEffect(() => {
3712
3803
  if (!session || !isEmbeddedInHop()) return;
3713
- const cycleSession = (dir: 1 | -1) => {
3714
- const list = [...sessionsRef.current]
3804
+ // ── Desktop switch HUD (the swipe filmstrip's desktop twin) ────────────
3805
+ // Recency ring, FROZEN when the HUD opens so neighbours don't reshuffle
3806
+ // mid-cycle. Stepping only moves the lit target; the switch is DEFERRED to
3807
+ // commit (modifier release / wheel settle) — one navigation, ⌘Tab-style,
3808
+ // instead of the old switch-per-press.
3809
+ const hudRing = (): SessionInfo[] =>
3810
+ [...sessionsRef.current]
3715
3811
  .filter((s) => s.type !== "port")
3716
- .sort((a, b) => (a.displayName || a.name).localeCompare(b.displayName || b.name));
3717
- if (list.length < 2) return;
3718
- const cur = list.findIndex((s) => s.name === activeSessionRoomRef.current || s.internalName === activeSessionRoomRef.current);
3719
- const next = list[(cur + dir + list.length) % list.length];
3720
- if (next) switchSessionRef.current?.(next);
3812
+ .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
3813
+ const hudStep = (dir: 1 | -1) => {
3814
+ const open = switchHudRef.current;
3815
+ if (open) {
3816
+ setSwitchHud({ ring: open.ring, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
3817
+ return;
3818
+ }
3819
+ const ring = hudRing();
3820
+ if (ring.length < 2) return;
3821
+ const curKey = activeSessionRoomRef.current;
3822
+ const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
3823
+ const start = cur < 0 ? 0 : cur;
3824
+ setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)) });
3721
3825
  };
3826
+ const hudCommit = () => {
3827
+ const open = switchHudRef.current;
3828
+ setSwitchHud(null);
3829
+ if (!open) return;
3830
+ const target = open.ring[open.index];
3831
+ const curKey = activeSessionRoomRef.current;
3832
+ if (target && target.name !== curKey && target.internalName !== curKey) {
3833
+ switchSessionRef.current?.(target);
3834
+ }
3835
+ };
3836
+ const hudCancel = () => setSwitchHud(null);
3722
3837
  // Back and forth between the two sessions you are actually working in,
3723
3838
  // without a trip through the palette. MRU, so pressing it twice returns
3724
3839
  // you — the switch itself pushes the room you left to the top of the
@@ -3745,6 +3860,12 @@ const App = () => {
3745
3860
  ? event.metaKey && !event.ctrlKey && !event.altKey
3746
3861
  : event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
3747
3862
  if (!mod) {
3863
+ if (event.key === "Escape" && switchHudRef.current) {
3864
+ event.preventDefault();
3865
+ event.stopPropagation();
3866
+ hudCancel();
3867
+ return;
3868
+ }
3748
3869
  if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
3749
3870
  event.preventDefault();
3750
3871
  event.stopPropagation();
@@ -3765,7 +3886,10 @@ const App = () => {
3765
3886
  : {}));
3766
3887
  return;
3767
3888
  }
3768
- if (key === "j") { grab(); cycleSession(shifted ? -1 : 1); return; }
3889
+ // ⌘J / ⌘L step the switch HUD left / right (J is left of L on the
3890
+ // keyboard), committing on ⌘ release — no Shift needed for the reverse.
3891
+ if (key === "j") { grab(); hudStep(-1); return; }
3892
+ if (key === "l") { grab(); hudStep(1); return; }
3769
3893
  if (key === ",") { grab(); setDrawerOpen((v) => !v); return; }
3770
3894
  if (key === "/" || (shifted && key === "?")) { grab(); setShortcutHelpOpen((v) => !v); return; }
3771
3895
  if (key === "e") {
@@ -3813,7 +3937,42 @@ const App = () => {
3813
3937
  }
3814
3938
  };
3815
3939
  window.addEventListener("keydown", onKey, true);
3816
- return () => window.removeEventListener("keydown", onKey, true);
3940
+ // Release the hold modifier (⌘ on mac, Ctrl elsewhere) → land on the lit
3941
+ // target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
3942
+ // releasing it while another key is up both leave the flag false.
3943
+ const onKeyUp = (event: KeyboardEvent) => {
3944
+ if (!switchHudRef.current) return;
3945
+ if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
3946
+ };
3947
+ window.addEventListener("keyup", onKeyUp, true);
3948
+ // Two-finger horizontal swipe = the literal parity with the phone swipe.
3949
+ // Horizontal-dominant wheel only; ignore vertical scroll, pinch, and any
3950
+ // scrollable panel/field; preventDefault so the browser's back/forward
3951
+ // swipe never fires. Steps per 60pt of travel, settles into a commit.
3952
+ let wheelAccum = 0;
3953
+ let wheelTimer: number | null = null;
3954
+ const onWheel = (event: WheelEvent) => {
3955
+ if (!session) return;
3956
+ const el = event.target as Element | null;
3957
+ if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
3958
+ if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) * 1.3) return;
3959
+ event.preventDefault();
3960
+ wheelAccum += event.deltaX;
3961
+ while (Math.abs(wheelAccum) >= 60) {
3962
+ const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
3963
+ wheelAccum -= dir * 60;
3964
+ hudStep(dir);
3965
+ }
3966
+ if (wheelTimer) window.clearTimeout(wheelTimer);
3967
+ wheelTimer = window.setTimeout(() => { wheelAccum = 0; hudCommit(); }, 320);
3968
+ };
3969
+ window.addEventListener("wheel", onWheel, { passive: false, capture: true });
3970
+ return () => {
3971
+ window.removeEventListener("keydown", onKey, true);
3972
+ window.removeEventListener("keyup", onKeyUp, true);
3973
+ window.removeEventListener("wheel", onWheel, true);
3974
+ if (wheelTimer) window.clearTimeout(wheelTimer);
3975
+ };
3817
3976
  // eslint-disable-next-line react-hooks/exhaustive-deps
3818
3977
  }, [session]);
3819
3978
 
@@ -3832,6 +3991,36 @@ const App = () => {
3832
3991
 
3833
3992
  return (
3834
3993
  <div className="app">
3994
+ {switchHud && (() => {
3995
+ const curKey = session?.room ?? sessionLabel;
3996
+ const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
3997
+ return (
3998
+ <div className="switch-hud" aria-hidden="true">
3999
+ <div className="switch-hud-window">
4000
+ <div className="switch-hud-strip"
4001
+ style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
4002
+ {switchHud.ring.map((s, i) => {
4003
+ const key = s.internalName || s.name;
4004
+ const isTarget = i === switchHud.index;
4005
+ const isCurrent = key === curKey;
4006
+ return (
4007
+ <div key={key}
4008
+ className={"switch-hud-card"
4009
+ + (isTarget ? " is-target" : "")
4010
+ + (isCurrent ? " is-current" : "")}>
4011
+ <span className="switch-hud-name">{s.displayName || s.name}</span>
4012
+ {isCurrent && <span className="switch-hud-now">now</span>}
4013
+ </div>
4014
+ );
4015
+ })}
4016
+ </div>
4017
+ </div>
4018
+ <div className="switch-hud-hint">
4019
+ {isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels
4020
+ </div>
4021
+ </div>
4022
+ );
4023
+ })()}
3835
4024
  <header className="topbar">
3836
4025
  <div className="brand">
3837
4026
  <div>
@@ -4640,7 +4829,7 @@ const App = () => {
4640
4829
  {[
4641
4830
  [isMacPlatform ? "⌘K" : "Ctrl+Shift+K", "session palette (type to filter, ↑↓, ⏎)"],
4642
4831
  [isMacPlatform ? "⌘E" : "Ctrl+Shift+E", "back to the last session (press again to return)"],
4643
- [isMacPlatform ? "⌘J / ⌘⇧J" : "Ctrl+Shift+J / +⇧", "next / previous session (by name)"],
4832
+ [isMacPlatform ? "⌘J / ⌘L" : "Ctrl+Shift+J / +L", "previous / next session (left / right)"],
4644
4833
  [isMacPlatform ? "⌘," : "Ctrl+Shift+,", "settings drawer"],
4645
4834
  [isMacPlatform ? "⌘F" : "Ctrl+Shift+F", "find in terminal"],
4646
4835
  ...(isMacPlatform ? [["⌘⇧V", "views — results agents published"]] : []),