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.
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
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
|
-
|
|
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={
|
|
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 ?
|
|
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