hop2 0.11.49 → 0.11.51

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.
@@ -16,6 +16,7 @@ import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSea
16
16
  import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
17
17
  import { tabTitle } from "./utils/tabTitle";
18
18
  import { remoteAppOwnsScreen } from "./utils/echoGuard";
19
+ import { urlAtCell, cellAtPoint } from "./utils/urlAtCell";
19
20
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
20
21
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
21
22
  import { MobileKeyboard } from "./components/MobileKeyboard";
@@ -1058,9 +1059,19 @@ const App = () => {
1058
1059
  // behind). Poll our own index.html for the bundle it now names; when it
1059
1060
  // differs from the one running, reload — but only while the tab is HIDDEN
1060
1061
  // and the terminal has no buffered input, so nobody ever sees it happen.
1062
+ // A window that is never hidden — a dedicated hop window left open on a
1063
+ // second display — never reloaded at all, and ran fixes-old code for days
1064
+ // ("the bell is still ringing" was a window on a bundle from before bells
1065
+ // went silent). Such a tab now gets told once per new bundle, and reloads
1066
+ // itself after a quarter hour with no keystroke, click or wheel in it: a
1067
+ // terminal reconnects and lands where it was, so an idle reload costs the
1068
+ // user nothing they were doing.
1061
1069
  const staleBundleRef = useRef(false);
1070
+ const STALE_IDLE_RELOAD_MS = 15 * 60 * 1000;
1062
1071
  useEffect(() => {
1063
1072
  let disposed = false;
1073
+ let noticedFor: string | null = null;
1074
+ let lastUserInputAt = Date.now();
1064
1075
  const loadedBundle = (() => {
1065
1076
  try { return new URL(import.meta.url).pathname.split("/").pop() || null; }
1066
1077
  catch { return null; }
@@ -1071,18 +1082,28 @@ const App = () => {
1071
1082
  const res = await fetch("/", { cache: "no-store" });
1072
1083
  const html = await res.text();
1073
1084
  const current = (html.match(/assets\/(index-[^"']+\.js)/) || [])[1];
1074
- if (!disposed && current && current !== loadedBundle) staleBundleRef.current = true;
1085
+ if (!disposed && current && current !== loadedBundle) {
1086
+ staleBundleRef.current = true;
1087
+ if (noticedFor !== current && !document.hidden) {
1088
+ noticedFor = current;
1089
+ pushNoticeRef.current?.("hop was updated — reload to pick it up (this tab reloads itself when hidden or idle)");
1090
+ }
1091
+ }
1075
1092
  } catch { /* offline — try again next tick */ }
1076
1093
  };
1077
1094
  const maybeReload = () => {
1078
- if (staleBundleRef.current && document.hidden) window.location.reload();
1095
+ if (!staleBundleRef.current) return;
1096
+ if (document.hidden || Date.now() - lastUserInputAt > STALE_IDLE_RELOAD_MS) window.location.reload();
1079
1097
  };
1098
+ const touch = () => { lastUserInputAt = Date.now(); };
1080
1099
  const interval = window.setInterval(() => { void check().then(maybeReload); }, 5 * 60 * 1000);
1081
1100
  document.addEventListener("visibilitychange", maybeReload);
1101
+ for (const ev of ["keydown", "pointerdown", "wheel", "touchstart"] as const) window.addEventListener(ev, touch, { passive: true });
1082
1102
  return () => {
1083
1103
  disposed = true;
1084
1104
  window.clearInterval(interval);
1085
1105
  document.removeEventListener("visibilitychange", maybeReload);
1106
+ for (const ev of ["keydown", "pointerdown", "wheel", "touchstart"] as const) window.removeEventListener(ev, touch);
1086
1107
  };
1087
1108
  }, []);
1088
1109
 
@@ -2089,7 +2110,16 @@ const App = () => {
2089
2110
  // window.open inside the click handler keeps the popup-blocker happy.
2090
2111
  terminal.loadAddon(new WebLinksAddon((event, uri) => {
2091
2112
  event.preventDefault();
2092
- window.open(uri, "_blank", "noopener");
2113
+ // Prefer the whole URL when the addon saw only the first row of one a
2114
+ // TUI hard-broke at the right edge (see utils/urlAtCell).
2115
+ let target = uri;
2116
+ const screen = containerRef.current?.querySelector(".xterm-screen") as HTMLElement | null;
2117
+ const cell = screen ? cellAtPoint(screen, containerRef.current?.querySelector(".xterm-rows") as HTMLElement | null, terminal.cols, terminal.rows, event.clientX, event.clientY) : null;
2118
+ if (cell) {
2119
+ const joined = extractUrlAtCell(terminal.buffer.active.viewportY + cell.row, cell.col);
2120
+ if (joined && joined.startsWith(uri) && joined.length > uri.length) target = joined;
2121
+ }
2122
+ window.open(target, "_blank", "noopener");
2093
2123
  }));
2094
2124
  terminal.open(containerRef.current);
2095
2125
 
@@ -3743,29 +3773,16 @@ const App = () => {
3743
3773
 
3744
3774
  // Reconstruct the URL under a tapped cell. Wrapped rows are joined into the
3745
3775
  // logical line first — OAuth URLs span many screen rows.
3776
+ // Shared with the wall tiles (utils/urlAtCell): joins soft-wrapped rows
3777
+ // and the hard breaks a TUI makes at the right edge.
3746
3778
  const extractUrlAtCell = useCallback((bufferRow: number, col: number): string | null => {
3747
3779
  const term = termRef.current;
3748
3780
  if (!term) return null;
3749
3781
  const buf = term.buffer.active;
3750
- let start = bufferRow;
3751
- while (start > 0 && buf.getLine(start)?.isWrapped) start--;
3752
- let text = "";
3753
- let offset = -1;
3754
- for (let r = start; r < buf.length; r++) {
3755
- const line = buf.getLine(r);
3756
- if (!line || (r > start && !line.isWrapped)) break;
3757
- if (r === bufferRow) offset = text.length + Math.min(col, term.cols - 1);
3758
- text += line.translateToString(false);
3759
- }
3760
- if (offset < 0) return null;
3761
- const urlRe = /https?:\/\/[^\s"'`<>]+/g;
3762
- let m: RegExpExecArray | null;
3763
- while ((m = urlRe.exec(text)) !== null) {
3764
- if (offset >= m.index && offset <= m.index + m[0].length) {
3765
- return m[0].replace(/[.,;:!?)\]}]+$/, "");
3766
- }
3767
- }
3768
- return null;
3782
+ return urlAtCell(
3783
+ (i) => { const l = buf.getLine(i); return l ? { text: l.translateToString(false), wrapped: l.isWrapped } : null; },
3784
+ buf.length, term.cols, bufferRow, col
3785
+ );
3769
3786
  }, []);
3770
3787
 
3771
3788
  // Enroll this device's platform authenticator (Touch ID / Face ID) as a
@@ -13,6 +13,7 @@ import {
13
13
  import { Terminal } from "@xterm/xterm";
14
14
  import { FitAddon } from "@xterm/addon-fit";
15
15
  import { WebLinksAddon } from "@xterm/addon-web-links";
16
+ import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
16
17
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
17
18
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
18
19
  import { CwdField } from "./CwdField";
@@ -363,6 +364,33 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
363
364
  const rescaleRef = useRef<() => void>(() => {});
364
365
  // Wired by the live effect; watch mode leaves them null so keys go nowhere.
365
366
  const sendInputRef = useRef<((data: string) => void) | null>(null);
367
+ // When the link addon last opened something from this tile — so the click
368
+ // that follows its mouseup does not open the same URL a second time.
369
+ const lastLinkOpenAtRef = useRef(0);
370
+ // The URL under a viewport point, reassembled across the rows it spans
371
+ // (utils/urlAtCell): Codex hard-breaks long URLs at the tile's edge, and
372
+ // the addon only joins rows the terminal itself wrapped.
373
+ const urlAtPoint = (clientX: number, clientY: number): string | null => {
374
+ const term = termRef.current;
375
+ const screen = boxRef.current?.querySelector(".xterm-screen") as HTMLElement | null;
376
+ if (!term || !screen) return null;
377
+ const rowsEl = boxRef.current?.querySelector(".xterm-rows") as HTMLElement | null;
378
+ const cell = cellAtPoint(screen, rowsEl, term.cols, term.rows, clientX, clientY);
379
+ const buf = term.buffer.active;
380
+ const url = cell ? urlAtCell(
381
+ (i) => { const l = buf.getLine(i); return l ? { text: l.translateToString(false), wrapped: l.isWrapped } : null; },
382
+ buf.length, term.cols, buf.viewportY + cell.row, cell.col
383
+ ) : null;
384
+ // Diagnostics for "clicking a link in a tile does nothing" reports:
385
+ // localStorage.hay_debug_links=1 prints what the click resolved to.
386
+ try {
387
+ if (localStorage.getItem("hay_debug_links")) {
388
+ const line = cell ? buf.getLine(buf.viewportY + cell.row) : null;
389
+ console.log("[links]", JSON.stringify({ cell, cols: term.cols, rows: term.rows, viewportY: buf.viewportY, len: buf.length, domRows: rowsEl?.children.length ?? null, line: line ? line.translateToString(false).slice(0, 90) : null, url }));
390
+ }
391
+ } catch { /* diagnostics only */ }
392
+ return url;
393
+ };
366
394
  const kbdEnhancedRef = useRef(false);
367
395
  // Throttle gate for typing-driven size ownership (below) — same 500ms
368
396
  // budget as the full-screen terminal's fit-on-type, and for the same
@@ -482,7 +510,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
482
510
  // stops the card from treating the click as a switch.
483
511
  term.loadAddon(new WebLinksAddon((event, uri) => {
484
512
  event.preventDefault();
485
- window.open(uri, "_blank", "noopener");
513
+ // Prefer the whole URL when the addon saw only the first row of a
514
+ // hard-broken one (its match ends at the tile's right edge).
515
+ const joined = urlAtPoint(event.clientX, event.clientY);
516
+ const target = joined && joined.startsWith(uri) && joined.length > uri.length ? joined : uri;
517
+ lastLinkOpenAtRef.current = Date.now();
518
+ window.open(target, "_blank", "noopener");
486
519
  }));
487
520
  term.open(box);
488
521
  termRef.current = term;
@@ -1070,7 +1103,22 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
1070
1103
  // bottom-anchored screen otherwise shows the CARD's background as a
1071
1104
  // pale strip — the "space on top".
1072
1105
  style={{ background: (theme as { background?: string } | undefined)?.background }}
1073
- onClick={live ? (e) => e.stopPropagation() : undefined}
1106
+ onClick={(e) => {
1107
+ if (live) {
1108
+ e.stopPropagation();
1109
+ // The addon opened it on mouseup. Otherwise a URL the addon could
1110
+ // not see as one — a continuation row of a hard break — is ours.
1111
+ if (Date.now() - lastLinkOpenAtRef.current < 600) return;
1112
+ const url = urlAtPoint(e.clientX, e.clientY);
1113
+ if (url) window.open(url, "_blank", "noopener");
1114
+ return;
1115
+ }
1116
+ // Not live yet: the click bubbles and focuses the tile as before,
1117
+ // AND a URL under it opens — the first click on a link is not a dud
1118
+ // that merely wakes the tile.
1119
+ const url = urlAtPoint(e.clientX, e.clientY);
1120
+ if (url) window.open(url, "_blank", "noopener");
1121
+ }}
1074
1122
  onPointerDown={live ? (e) => e.stopPropagation() : undefined}
1075
1123
  >
1076
1124
  <div className="switcher-focus-term" ref={boxRef} />
@@ -2657,11 +2705,22 @@ export const SessionSwitcher = ({
2657
2705
 
2658
2706
  const now = Date.now();
2659
2707
 
2708
+ // WHY the bell rang, so an amber dot on a Codex session — which rings at
2709
+ // the end of every turn — reads "finished", not "needs your input".
2710
+ const bellWhy = (s: SwitcherSession) => {
2711
+ switch (s.attentionReason) {
2712
+ case "ask": return s.attentionNote ? `Asking: ${s.attentionNote}` : "Asking you something";
2713
+ case "finished": return "Finished a turn (bell)";
2714
+ case "view": return s.attentionNote ? `Published: ${s.attentionNote}` : "Published a view";
2715
+ case "working": return "Still working — rang mid-turn, nothing needs you yet";
2716
+ default: return "Bell rung since last viewed";
2717
+ }
2718
+ };
2660
2719
  const dots = (s: SwitcherSession) =>
2661
2720
  (s.bellUnseen || s.unread) && (
2662
2721
  <span
2663
2722
  className={`attention-dot ${s.bellUnseen ? "bell" : "output"}`}
2664
- title={s.bellUnseen ? "Bell rung since last viewed" : "New output since last viewed"}
2723
+ title={s.bellUnseen ? bellWhy(s) : "New output since last viewed"}
2665
2724
  />
2666
2725
  );
2667
2726
 
@@ -0,0 +1,119 @@
1
+ // The URL under a terminal cell, reassembled across the rows it spans.
2
+ //
3
+ // xterm's web-links addon joins rows only when the terminal itself wrapped
4
+ // them (the `isWrapped` flag). A TUI that lays out its own text — Codex,
5
+ // ratatui apps generally — breaks a long URL at the column boundary with a
6
+ // HARD break: each row is positioned on its own, nothing is flagged wrapped,
7
+ // and the addon sees two unrelated lines. In a wide full-screen terminal the
8
+ // URL fits one row and clicks work; in a narrow tile it breaks and the same
9
+ // click opens a truncated fragment or nothing. So this joins a row that runs
10
+ // to the right edge with the row below when the continuation looks like the
11
+ // rest of a URL, and searches the joined text for the URL covering the cell.
12
+
13
+ export type CellLine = { text: string; wrapped: boolean };
14
+
15
+ /**
16
+ * The (row, col) under a viewport point. With the DOM renderer each row is
17
+ * an element, so the row comes from whichever one contains the point —
18
+ * exact even when the terminal is scaled, cropped, or bottom-anchored
19
+ * inside a tile, where dividing the screen's height by `rows` was off by
20
+ * enough to land on the wrong line. Canvas/WebGL renderers keep no row
21
+ * elements; those fall back to the proportional split.
22
+ */
23
+ export const cellAtPoint = (
24
+ screen: HTMLElement,
25
+ rowsEl: HTMLElement | null,
26
+ cols: number,
27
+ rows: number,
28
+ clientX: number,
29
+ clientY: number
30
+ ): { row: number; col: number } | null => {
31
+ const sr = screen.getBoundingClientRect();
32
+ if (clientX < sr.left || clientX >= sr.right) return null;
33
+ const col = Math.floor(((clientX - sr.left) / sr.width) * cols);
34
+ const kids = rowsEl ? Array.from(rowsEl.children) as HTMLElement[] : [];
35
+ if (kids.length) {
36
+ const row = kids.findIndex((el) => { const r = el.getBoundingClientRect(); return clientY >= r.top && clientY < r.bottom; });
37
+ return row < 0 ? null : { row, col };
38
+ }
39
+ if (clientY < sr.top || clientY >= sr.bottom) return null;
40
+ return { row: Math.floor(((clientY - sr.top) / sr.height) * rows), col };
41
+ };
42
+
43
+ const URL_CHAR = /^[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]/;
44
+ const URL_RE = /https?:\/\/[^\s"'`<>]+/g;
45
+
46
+ /**
47
+ * Does `next` read as the hard-broken continuation of `prev` at width `cols`?
48
+ * Three signs together: `prev` runs to the right edge, the URL on it is what
49
+ * reaches the edge (not prose that happens to end there), and the first
50
+ * token of `next` looks like more URL — punctuation a URL carries, or a
51
+ * token too long for a word. "Then the agent continued." after a URL that
52
+ * ends at the edge is prose, and stays separate.
53
+ */
54
+ const hardContinues = (prev: CellLine | null, next: CellLine | null, cols: number) => {
55
+ if (!prev || !next) return false;
56
+ const prevText = prev.text.replace(/\s+$/, "");
57
+ if (prevText.length < cols - 1) return false;
58
+ let endsInUrl = false;
59
+ URL_RE.lastIndex = 0;
60
+ let m: RegExpExecArray | null;
61
+ while ((m = URL_RE.exec(prevText)) !== null) endsInUrl = m.index + m[0].length >= prevText.length;
62
+ if (!endsInUrl) return false;
63
+ const token = next.text.replace(/^\s+/, "").split(/\s/)[0] || "";
64
+ return token.length > 0 && URL_CHAR.test(token) && (/[/?=&%#._-]/.test(token) || token.length > 12);
65
+ };
66
+
67
+ /**
68
+ * @param getLine buffer accessor (absolute row → line, or null)
69
+ * @param length buffer length (rows)
70
+ * @param cols terminal width
71
+ * @param bufferRow / col the cell clicked
72
+ */
73
+ export const urlAtCell = (
74
+ getLine: (row: number) => CellLine | null,
75
+ length: number,
76
+ cols: number,
77
+ bufferRow: number,
78
+ col: number
79
+ ): string | null => {
80
+ const at = (r: number) => (r >= 0 && r < length ? getLine(r) : null);
81
+ if (!at(bufferRow)) return null;
82
+ // Walk up to the logical start: soft wraps, or hard continuations.
83
+ let start = bufferRow;
84
+ let hops = 0;
85
+ while (start > 0 && hops < 6) {
86
+ const cur = at(start);
87
+ const prev = at(start - 1);
88
+ if (!cur || !prev) break;
89
+ if (cur.wrapped || hardContinues(prev, cur, cols)) { start--; hops++; } else break;
90
+ }
91
+ // Walk down, joining; hard continuations drop their indentation.
92
+ let text = "";
93
+ let offset = -1;
94
+ let r = start;
95
+ let joined = 0;
96
+ while (r < length && joined < 8) {
97
+ const line = at(r);
98
+ if (!line) break;
99
+ if (r > start) {
100
+ const prev = at(r - 1);
101
+ if (!(line.wrapped || hardContinues(prev, line, cols))) break;
102
+ }
103
+ const lead = line.wrapped || r === start ? 0 : line.text.length - line.text.replace(/^\s+/, "").length;
104
+ const piece = line.text.slice(lead);
105
+ if (r === bufferRow) offset = text.length + Math.max(0, Math.min(col, cols - 1) - lead);
106
+ text += piece;
107
+ r++;
108
+ joined++;
109
+ }
110
+ if (offset < 0) return null;
111
+ let m: RegExpExecArray | null;
112
+ URL_RE.lastIndex = 0;
113
+ while ((m = URL_RE.exec(text)) !== null) {
114
+ if (offset >= m.index && offset <= m.index + m[0].length) {
115
+ return m[0].replace(/[.,;:!?)\]}]+$/, "");
116
+ }
117
+ }
118
+ return null;
119
+ };
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { urlAtCell } from "../src/utils/urlAtCell";
3
+
4
+ const COLS = 42;
5
+ const pad = (s: string) => s.padEnd(COLS, " ");
6
+ const buf = (rows: Array<[string, boolean?]>) => {
7
+ const lines = rows.map(([text, wrapped]) => ({ text: pad(text), wrapped: !!wrapped }));
8
+ return { get: (r: number) => lines[r] ?? null, length: lines.length };
9
+ };
10
+
11
+ describe("urlAtCell", () => {
12
+ it("finds a URL on one row and trims trailing punctuation", () => {
13
+ const b = buf([["see https://example.com/a/b, then"]]);
14
+ expect(urlAtCell(b.get, b.length, COLS, 0, 10)).toBe("https://example.com/a/b");
15
+ expect(urlAtCell(b.get, b.length, COLS, 0, 2)).toBeNull();
16
+ });
17
+
18
+ it("joins rows the terminal soft-wrapped (isWrapped), like the addon does", () => {
19
+ const b = buf([["https://example.com/0123456789012345678901"], ["23456789/tail ok", true]]);
20
+ expect(urlAtCell(b.get, b.length, COLS, 0, 5)).toBe("https://example.com/012345678901234567890123456789/tail");
21
+ expect(urlAtCell(b.get, b.length, COLS, 1, 3)).toBe("https://example.com/012345678901234567890123456789/tail");
22
+ });
23
+
24
+ it("joins a HARD break a TUI made at the right edge (Codex): no wrapped flag, continuation indented", () => {
25
+ // Row 0 fills the width; row 1 is positioned by the app with an indent.
26
+ const b = buf([["https://auth.example.com/authorize?client="], [" abcdef&state=xyz"]]);
27
+ expect(b.get(0)!.text.replace(/\s+$/, "").length).toBe(COLS);
28
+ expect(urlAtCell(b.get, b.length, COLS, 0, 12)).toBe("https://auth.example.com/authorize?client=abcdef&state=xyz");
29
+ // A click on the continuation row resolves to the same URL.
30
+ expect(urlAtCell(b.get, b.length, COLS, 1, 6)).toBe("https://auth.example.com/authorize?client=abcdef&state=xyz");
31
+ });
32
+
33
+ it("does not glue an unrelated next row onto a URL that merely ends at the edge", () => {
34
+ const b = buf([["https://example.com/0123456789012345678901"], ["Then the agent continued."]]);
35
+ expect(b.get(0)!.text.replace(/\s+$/, "").length).toBe(COLS);
36
+ expect(urlAtCell(b.get, b.length, COLS, 0, 5)).toBe("https://example.com/0123456789012345678901");
37
+ });
38
+
39
+ it("a long unpunctuated remainder still joins; a short word does not", () => {
40
+ const b = buf([["https://example.com/0123456789012345678901"], [" abcdefghijklmnop rest"]]);
41
+ expect(urlAtCell(b.get, b.length, COLS, 0, 5)).toBe("https://example.com/0123456789012345678901abcdefghijklmnop");
42
+ const c = buf([["https://example.com/0123456789012345678901"], [" docs are here"]]);
43
+ expect(urlAtCell(c.get, c.length, COLS, 0, 5)).toBe("https://example.com/0123456789012345678901");
44
+ });
45
+
46
+ it("returns null with no URL under the cell", () => {
47
+ const b = buf([["nothing here"]]);
48
+ expect(urlAtCell(b.get, b.length, COLS, 0, 3)).toBeNull();
49
+ });
50
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.49",
3
+ "version": "0.11.51",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {