hop2 0.11.70 → 0.11.71

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.
@@ -0,0 +1,133 @@
1
+ // OSC 8 hyperlinks survive the serialized attach snapshot.
2
+ //
3
+ // A room's attach replay is the headless grid serialized by xterm's
4
+ // serialize addon, which carries colours and styles but not hyperlinks: a
5
+ // cell's link id (OSC 8) is dropped. Codex prints its markdown links as a
6
+ // TITLE with the URL riding underneath, so once a client attached — the
7
+ // usual way of reading a session — every link already on screen was plain
8
+ // text: "codex tile links are not clickable". Live output after the attach
9
+ // still carried them, which is why it looked intermittent.
10
+ //
11
+ // Rather than fork the addon, the serialized string is walked in step with
12
+ // the buffer it came from. The addon's emission is regular: per row, each
13
+ // cell of width > 0 either appends its characters or, when empty, is counted
14
+ // and later skipped with `ESC[nC` (after an optional `ESC[nX`); rows end
15
+ // with `\r\n`, or nothing when the next row is a wrap; width-0 cells (the
16
+ // second half of a wide char) are not emitted. So printable text maps onto
17
+ // cells one by one, and a link run can be wrapped where its first cell is
18
+ // consumed and closed where it ends. Any desync (a cell whose characters are
19
+ // not what the string says next) returns the original string unchanged — a
20
+ // snapshot without links beats a corrupted one.
21
+
22
+ export type LinkCell = { chars: string; width: number; urlId: number };
23
+
24
+ export type InjectOptions = {
25
+ /** Cell at (buffer row, col) — null past the end. */
26
+ getCell: (row: number, col: number) => LinkCell | null;
27
+ cols: number;
28
+ /** First buffer row the serialized string covers. */
29
+ startRow: number;
30
+ /** Number of buffer rows it covers. */
31
+ rowCount: number;
32
+ /** The link target for a cell's link id, if any. */
33
+ uriOf: (urlId: number) => string | undefined;
34
+ };
35
+
36
+ const ESC = "\x1b";
37
+ const BEL = "\x07";
38
+
39
+ /** Length of the escape sequence starting at `i` (which is ESC). */
40
+ const escapeLength = (s: string, i: number): number => {
41
+ const next = s[i + 1];
42
+ if (next === "[") {
43
+ let j = i + 2;
44
+ while (j < s.length) {
45
+ const c = s.charCodeAt(j);
46
+ if (c >= 0x40 && c <= 0x7e) return j + 1 - i;
47
+ j++;
48
+ }
49
+ return s.length - i;
50
+ }
51
+ if (next === "]") {
52
+ const bel = s.indexOf(BEL, i + 2);
53
+ const st = s.indexOf(ESC + "\\", i + 2);
54
+ const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
55
+ if (end === -1) return s.length - i;
56
+ return end + (s[end] === BEL ? 1 : 2) - i;
57
+ }
58
+ return Math.min(2, s.length - i);
59
+ };
60
+
61
+ /** `ESC[nC` → n, else 0. */
62
+ const cursorForward = (seq: string): number => {
63
+ const m = /^\x1b\[(\d*)C$/.exec(seq);
64
+ return m ? Math.max(1, Number(m[1] || 1)) : 0;
65
+ };
66
+
67
+ export const injectHyperlinks = (serialized: string, o: InjectOptions): string => {
68
+ const endRow = o.startRow + o.rowCount; // exclusive
69
+ let row = o.startRow;
70
+ let col = 0;
71
+ let out = "";
72
+ let openId = 0;
73
+ const open = (id: number): boolean => {
74
+ const uri = o.uriOf(id);
75
+ if (!uri) return false;
76
+ out += `${ESC}]8;id=hop${id};${uri}${ESC}\\`;
77
+ openId = id;
78
+ return true;
79
+ };
80
+ const close = () => {
81
+ if (openId) out += `${ESC}]8;;${ESC}\\`;
82
+ openId = 0;
83
+ };
84
+ // The content cell at the cursor, skipping the placeholders a wide char
85
+ // leaves behind. Null past the row's end.
86
+ const contentCell = (): LinkCell | null => {
87
+ while (col < o.cols) {
88
+ const cell = o.getCell(row, col);
89
+ if (!cell) return null;
90
+ if (cell.width > 0) return cell;
91
+ col++;
92
+ }
93
+ return null;
94
+ };
95
+ let i = 0;
96
+ while (i < serialized.length) {
97
+ const ch = serialized[i];
98
+ if (ch === ESC) {
99
+ const n = escapeLength(serialized, i);
100
+ const seq = serialized.slice(i, i + n);
101
+ // A link is a property of the characters written while it is open;
102
+ // cursor moves and erases must not carry it, so close across them.
103
+ // Styling (SGR) inside a run is fine: the id keeps the run one link.
104
+ if (!/m$/.test(seq)) close();
105
+ const fwd = cursorForward(seq);
106
+ if (fwd && row < endRow) col = Math.min(o.cols, col + fwd);
107
+ out += seq;
108
+ i += n;
109
+ continue;
110
+ }
111
+ if (ch === "\r" || ch === "\n") {
112
+ close();
113
+ out += ch;
114
+ if (ch === "\n") { row++; col = 0; }
115
+ i++;
116
+ continue;
117
+ }
118
+ // Printable: the next content cell.
119
+ if (row >= endRow) { close(); out += serialized.slice(i); return out; }
120
+ if (col >= o.cols) { row++; col = 0; } // a wrapped row ran straight on
121
+ const cell = contentCell();
122
+ if (!cell || !cell.chars || !serialized.startsWith(cell.chars, i)) return serialized;
123
+ if (cell.urlId !== openId) {
124
+ close();
125
+ if (cell.urlId) open(cell.urlId);
126
+ }
127
+ out += cell.chars;
128
+ i += cell.chars.length;
129
+ col += cell.width;
130
+ }
131
+ close();
132
+ return out;
133
+ };
@@ -1,7 +1,7 @@
1
1
  // Termshare Server Library
2
2
  // Export everything needed for embedding into other apps
3
3
 
4
- export { Room, RoomManager, type SocketAdapter, type ClientInfo, type RoomSummary } from "./rooms";
4
+ export { Room, RoomManager, setDefaultViewerColors, getDefaultViewerColors, type SocketAdapter, type ClientInfo, type RoomSummary } from "./rooms";
5
5
  export { createPty, type PtyFactory } from "./pty";
6
6
  export { sanitizeName, sanitizeRoom } from "./utils";
7
7
 
@@ -87,6 +87,27 @@ export type ClientInfo = {
87
87
  nudge?: boolean;
88
88
  };
89
89
 
90
+ // The colors of the LAST viewer anyone had, host-wide. A room usually
91
+ // starts headless — the daemon creates it, the app launches and asks "what's
92
+ // your background?" within milliseconds, and the browser attaches only
93
+ // after — so a per-room memory of "its own last viewer" is empty exactly
94
+ // when it is needed. Codex and Claude pick light vs dark from this answer
95
+ // ONCE, at startup: told the dark default, Codex drew a charcoal composer
96
+ // box on the user's white theme for the rest of the session. The newest
97
+ // viewer's colors are what the user is looking at; every new room answers
98
+ // with them until a viewer of its own says otherwise. Persisted by the host
99
+ // so a restart (restore, keeper re-adoption) keeps the answer.
100
+ const viewerColors = { bg: "0d1117", fg: "e6edf3" };
101
+ const HEX6 = /^[0-9a-f]{6}$/i;
102
+ /** Remember the viewer's colors for every room created from now on. Returns true when they changed. */
103
+ export function setDefaultViewerColors(bg?: string | null, fg?: string | null): boolean {
104
+ let changed = false;
105
+ if (bg && HEX6.test(bg) && bg.toLowerCase() !== viewerColors.bg) { viewerColors.bg = bg.toLowerCase(); changed = true; }
106
+ if (fg && HEX6.test(fg) && fg.toLowerCase() !== viewerColors.fg) { viewerColors.fg = fg.toLowerCase(); changed = true; }
107
+ return changed;
108
+ }
109
+ export const getDefaultViewerColors = () => ({ ...viewerColors });
110
+
90
111
  export type RoomCreateOptions = {
91
112
  cwd: string;
92
113
  env?: Record<string, string>;
@@ -286,9 +307,9 @@ export class Room extends EventEmitter {
286
307
  // getOutputSince hand out stable cursors into an ever-trimming ring.
287
308
  private outputStart = 0;
288
309
  // Last viewer's terminal colors (6-hex, no '#'), used to answer OSC 10/11
289
- // while headless. Defaults to the web client's dark surface.
290
- private clientBg = "0d1117";
291
- private clientFg = "e6edf3";
310
+ // while headless. Starts as the host-wide last viewer's (see viewerColors).
311
+ private clientBg = viewerColors.bg;
312
+ private clientFg = viewerColors.fg;
292
313
  private alternateScreen = false;
293
314
  // Enhanced keyboard reporting requested by the remote app (kitty keyboard protocol
294
315
  // or xterm modifyOtherKeys). Tracked like alternateScreen so a reattaching client
@@ -1364,8 +1385,9 @@ export class Room extends EventEmitter {
1364
1385
  */
1365
1386
  /** Remember the viewer's terminal colors for headless OSC 10/11 answers. */
1366
1387
  setClientColors(bg?: string | null, fg?: string | null) {
1367
- if (bg && /^[0-9a-f]{6}$/i.test(bg)) this.clientBg = bg.toLowerCase();
1368
- if (fg && /^[0-9a-f]{6}$/i.test(fg)) this.clientFg = fg.toLowerCase();
1388
+ if (bg && HEX6.test(bg)) this.clientBg = bg.toLowerCase();
1389
+ if (fg && HEX6.test(fg)) this.clientFg = fg.toLowerCase();
1390
+ setDefaultViewerColors(bg, fg);
1369
1391
  }
1370
1392
 
1371
1393
  getOutputSince(
@@ -16,7 +16,15 @@
16
16
  // room silently falls back to the raw-tail snapshot (+ wiggle), so a broken
17
17
  // dependency degrades to the old behavior instead of breaking attach.
18
18
 
19
+ import { injectHyperlinks } from "./hyperlinks";
20
+
21
+ type HeadlessCell = { getChars(): string; getWidth(): number; extended?: { urlId?: number } };
19
22
  type HeadlessTerminal = {
23
+ rows?: number;
24
+ cols?: number;
25
+ buffer?: { active: { length: number; getLine(y: number): { getCell(x: number): HeadlessCell | undefined } | undefined } };
26
+ // Internals the serialize addon drops (OSC 8 targets); read guardedly.
27
+ _core?: { _oscLinkService?: { getLinkData(id: number): { uri?: string } | undefined; _dataByLinkId?: Map<number, unknown> } };
20
28
  write(data: string, callback?: () => void): void;
21
29
  resize(cols: number, rows: number): void;
22
30
  loadAddon(addon: unknown): void;
@@ -96,6 +104,26 @@ export type ScreenGrid = {
96
104
  dispose(): void;
97
105
  };
98
106
 
107
+ // The serialize addon drops OSC 8 hyperlinks; put them back from the
108
+ // buffer's own link ids (see hyperlinks.ts). Skipped outright when the grid
109
+ // has registered no link at all — the common case, at no cost.
110
+ const withHyperlinks = (term: HeadlessTerminal, serialized: string, scrollback: number): string => {
111
+ try {
112
+ const svc = term._core?._oscLinkService;
113
+ const buf = term.buffer?.active;
114
+ if (!svc || !buf || !term.rows) return serialized;
115
+ if (svc._dataByLinkId && svc._dataByLinkId.size === 0) return serialized;
116
+ const rowCount = Math.min(buf.length, scrollback + term.rows);
117
+ return injectHyperlinks(serialized, {
118
+ cols: term.cols ?? 0, startRow: buf.length - rowCount, rowCount,
119
+ getCell: (y, x) => { const c = buf.getLine(y)?.getCell(x); return c ? { chars: c.getChars(), width: c.getWidth(), urlId: c.extended?.urlId || 0 } : null; },
120
+ uriOf: (id) => svc.getLinkData(id)?.uri
121
+ });
122
+ } catch {
123
+ return serialized;
124
+ }
125
+ };
126
+
99
127
  export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null => {
100
128
  if (!deps) return null;
101
129
  let term: HeadlessTerminal;
@@ -165,7 +193,8 @@ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null
165
193
  term.write("", () => {
166
194
  if (disposed) { callback(null); return; }
167
195
  try {
168
- callback(addon.serialize({ scrollback: Math.max(0, Math.min(scrollback, GRID_SCROLLBACK)) }));
196
+ const sb = Math.max(0, Math.min(scrollback, GRID_SCROLLBACK));
197
+ callback(withHyperlinks(term, addon.serialize({ scrollback: sb }), sb));
169
198
  } catch {
170
199
  callback(null);
171
200
  }
@@ -0,0 +1,99 @@
1
+ // OSC 8 links survive a serialize → inject → replay round trip through real
2
+ // xterm: the replayed terminal's cells carry the same targets, and its text
3
+ // is the serialized terminal's text.
4
+ import { describe, expect, it } from "vitest";
5
+ import { injectHyperlinks } from "../src/hyperlinks";
6
+
7
+ type Cell = { getChars(): string; getWidth(): number; extended?: { urlId?: number } };
8
+ type Term = {
9
+ rows: number; cols: number;
10
+ buffer: { active: { length: number; getLine(y: number): { getCell(x: number): Cell | undefined; translateToString(trim?: boolean): string } | undefined } };
11
+ write(d: string, cb?: () => void): void;
12
+ loadAddon(a: unknown): void;
13
+ _core: { _oscLinkService: { getLinkData(id: number): { uri: string } | undefined } };
14
+ };
15
+
16
+ const g = globalThis as Record<string, unknown>;
17
+ if (!g.window) g.window = g;
18
+ if (!g.self) g.self = g;
19
+
20
+ const make = async (cols = 40, rows = 6, scrollback = 100) => {
21
+ // UMD/CJS under ESM interop: the named exports may live on `default`
22
+ // (screenGrid.ts resolves them the same way).
23
+ const h = await import("@xterm/headless") as { Terminal?: unknown; default?: { Terminal?: unknown } };
24
+ const sz = await import("@xterm/addon-serialize") as { SerializeAddon?: unknown; default?: { SerializeAddon?: unknown } };
25
+ const Terminal = (h.Terminal ?? h.default?.Terminal) as new (o: Record<string, unknown>) => Term;
26
+ const SerializeAddon = (sz.SerializeAddon ?? sz.default?.SerializeAddon) as new () => { serialize(o: { scrollback?: number }): string };
27
+ const term = new Terminal({ cols, rows, scrollback, allowProposedApi: true });
28
+ const addon = new SerializeAddon();
29
+ term.loadAddon(addon);
30
+ const write = (d: string) => new Promise<void>((r) => term.write(d, r));
31
+ const serialize = (sb = scrollback) => {
32
+ const s = addon.serialize({ scrollback: sb });
33
+ const buf = term.buffer.active;
34
+ const rowCount = Math.min(buf.length, sb + term.rows);
35
+ return injectHyperlinks(s, {
36
+ cols: term.cols, startRow: buf.length - rowCount, rowCount,
37
+ getCell: (y, x) => { const c = buf.getLine(y)?.getCell(x); return c ? { chars: c.getChars(), width: c.getWidth(), urlId: c.extended?.urlId || 0 } : null; },
38
+ uriOf: (id) => term._core._oscLinkService.getLinkData(id)?.uri
39
+ });
40
+ };
41
+ return { term, write, serialize };
42
+ };
43
+
44
+ const linksOf = (term: Term) => {
45
+ const buf = term.buffer.active;
46
+ const out: { row: number; text: string; uri: string }[] = [];
47
+ for (let y = 0; y < buf.length; y++) {
48
+ const line = buf.getLine(y)!;
49
+ let cur: { row: number; text: string; uri: string } | null = null;
50
+ for (let x = 0; x < term.cols; x++) {
51
+ const c = line.getCell(x)!;
52
+ const uri = c.extended?.urlId ? term._core._oscLinkService.getLinkData(c.extended.urlId)?.uri || "" : "";
53
+ if (uri && cur && cur.uri === uri) cur.text += c.getChars();
54
+ else if (uri) { cur = { row: y, text: c.getChars(), uri }; out.push(cur); }
55
+ else cur = null;
56
+ }
57
+ }
58
+ return out;
59
+ };
60
+ const textOf = (term: Term) => Array.from({ length: term.buffer.active.length }, (_, y) => term.buffer.active.getLine(y)!.translateToString(true)).join("\n").replace(/\n+$/, "");
61
+
62
+ const L = (uri: string, text: string) => `\x1b]8;;${uri}\x07${text}\x1b]8;;\x07`;
63
+
64
+ describe("injectHyperlinks — OSC 8 links ride the serialized snapshot", () => {
65
+ it("a titled link (Codex's markdown link) comes back as a link, text intact", async () => {
66
+ const a = await make(80);
67
+ await a.write(`• Wrote the plan: ${L("https://hop.zhoulab.io/view/s_1/plan.html/inline", "proposed next experiments")}.\r\nnext line\r\n`);
68
+ const b = await make(80);
69
+ await b.write(a.serialize());
70
+ expect(textOf(b.term)).toBe(textOf(a.term));
71
+ expect(linksOf(b.term)).toEqual([{ row: 0, text: "proposed next experiments", uri: "https://hop.zhoulab.io/view/s_1/plan.html/inline" }]);
72
+ });
73
+
74
+ it("colours inside a link, empty gaps, wide characters and a wrapped row all keep their places", async () => {
75
+ const a = await make(20, 5);
76
+ await a.write(`\x1b[31mred\x1b[0m ${L("https://x.test/a", "li\x1b[1mnk\x1b[22m 漢字")} tail\r\n`);
77
+ await a.write(`${L("https://x.test/b", "0123456789abcdefghijKLMNOP")}\r\n`); // wraps at 20
78
+ await a.write(`gap ${L("https://x.test/c", "c")}\x1b[5C${L("https://x.test/d", "d")}\r\n`);
79
+ const b = await make(20, 5);
80
+ await b.write(a.serialize());
81
+ expect(textOf(b.term)).toBe(textOf(a.term));
82
+ expect(linksOf(b.term)).toEqual(linksOf(a.term));
83
+ expect(linksOf(b.term).map((l) => l.text)).toEqual(["link 漢字", "0123456789abcdefghij", "KLMNOP", "c", "d"]);
84
+ });
85
+
86
+ it("a snapshot with scrollback trimmed off keeps the links of the rows it covers", async () => {
87
+ const a = await make(30, 3, 50);
88
+ for (let i = 0; i < 8; i++) await a.write(`${L(`https://x.test/${i}`, `line ${i}`)}\r\n`);
89
+ const b = await make(30, 3, 50);
90
+ await b.write(a.serialize(2)); // 2 rows of history + 3 of screen
91
+ expect(linksOf(b.term).map((l) => l.uri)).toEqual(["https://x.test/4", "https://x.test/5", "https://x.test/6", "https://x.test/7"]);
92
+ });
93
+
94
+ it("a desync returns the string untouched rather than a corrupted screen", () => {
95
+ const s = "\x1b[31mhello\x1b[0m";
96
+ const out = injectHyperlinks(s, { cols: 10, startRow: 0, rowCount: 1, getCell: () => ({ chars: "x", width: 1, urlId: 1 }), uriOf: () => "https://x" });
97
+ expect(out).toBe(s);
98
+ });
99
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { RoomManager } from "../src/rooms";
2
+ import { RoomManager, getDefaultViewerColors, setDefaultViewerColors } from "../src/rooms";
3
3
  import type { PtyFactory } from "../src/pty";
4
4
 
5
5
  type Message = { type: string; [key: string]: unknown };
@@ -766,6 +766,28 @@ describe("Room", () => {
766
766
  const factory: PtyFactory = () => {
767
767
  ptyInstance = new FakePty() as unknown as FakePty;
768
768
  return ptyInstance as any;
769
+
770
+ it("a NEW headless room answers with the colors the last viewer (of any room) had — a light theme stays light", () => {
771
+ const ptys: FakePty[] = [];
772
+ const factory: PtyFactory = () => { const p = new FakePty(); ptys.push(p); return p as any; };
773
+ const manager = new RoomManager(factory);
774
+ const first = manager.getRoom("seen", { cols: 80, rows: 24 }, "/tmp");
775
+ // The browser attached on a white theme (its bg/fg ride on the attach URL).
776
+ first.setClientColors("ffffff", "1f2328");
777
+ expect(getDefaultViewerColors()).toEqual({ bg: "ffffff", fg: "1f2328" });
778
+
779
+ // The next room is created by the daemon and probed by Codex before any
780
+ // browser attaches: it is told white, not the dark default.
781
+ manager.getRoom("fresh", { cols: 80, rows: 24 }, "/tmp");
782
+ ptys[1].emit("\x1b]10;?\x07\x1b]11;?\x1b\\");
783
+ const answered = ptys[1].writes.join("");
784
+ expect(answered).toContain("]11;rgb:ffff/ffff/ffff");
785
+ expect(answered).toContain("]10;rgb:1f1f/2323/2828");
786
+
787
+ // Garbage never replaces a good answer; the dark default is restorable.
788
+ expect(setDefaultViewerColors("#fff", "nope")).toBe(false);
789
+ expect(setDefaultViewerColors("0d1117", "e6edf3")).toBe(true);
790
+ });
769
791
  };
770
792
  const manager = new RoomManager(factory);
771
793
  const room = manager.getRoom(roomId, initialSize, "/tmp");
@@ -16,7 +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
+ import { urlAtCell, cellAtPoint, oscUrlAtCell } from "./utils/urlAtCell";
20
20
  import { enableMathHover } from "./utils/mathLinkProvider";
21
21
  import { getMathTip } from "./utils/mathTooltip";
22
22
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
@@ -2147,6 +2147,15 @@ const App = () => {
2147
2147
  scrollSensitivity: 4,
2148
2148
  fastScrollSensitivity: 12,
2149
2149
  minimumContrastRatio: contrastFloorFor(themeMode),
2150
+ // OSC 8 hyperlinks — Codex prints a markdown link as its title with the
2151
+ // URL underneath. xterm's default for these is a confirm() dialog;
2152
+ // open them like the addon's links instead.
2153
+ linkHandler: {
2154
+ activate: (event, uri) => {
2155
+ event.preventDefault();
2156
+ window.open(uri, "_blank", "noopener");
2157
+ }
2158
+ },
2150
2159
  theme: resolveTerminalTheme(themeMode)
2151
2160
  });
2152
2161
  const fitAddon = new FitAddon();
@@ -3866,7 +3875,7 @@ const App = () => {
3866
3875
  return urlAtCell(
3867
3876
  (i) => { const l = buf.getLine(i); return l ? { text: l.translateToString(false), wrapped: l.isWrapped } : null; },
3868
3877
  buf.length, term.cols, bufferRow, col
3869
- );
3878
+ ) || oscUrlAtCell(term, bufferRow, col);
3870
3879
  }, []);
3871
3880
 
3872
3881
  // Enroll this device's platform authenticator (Touch ID / Face ID) as a
@@ -19,7 +19,7 @@ import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
19
19
  import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
20
20
  import { installDragSelect } from "../utils/dragSelect";
21
21
  import { enableViewBlocks } from "../utils/viewBlocks";
22
- import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
22
+ import { urlAtCell, cellAtPoint, oscUrlAtCell } from "../utils/urlAtCell";
23
23
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
24
24
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
25
25
  import { CwdField } from "./CwdField";
@@ -396,10 +396,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
396
396
  const rowsEl = boxRef.current?.querySelector(".xterm-rows") as HTMLElement | null;
397
397
  const cell = cellAtPoint(screen, rowsEl, term.cols, term.rows, clientX, clientY);
398
398
  const buf = term.buffer.active;
399
- const url = cell ? urlAtCell(
399
+ // A URL in the text, reassembled across rows — or the OSC 8 target a
400
+ // TUI hung on a title (Codex's markdown links), which has no URL text.
401
+ const url = cell ? (urlAtCell(
400
402
  (i) => { const l = buf.getLine(i); return l ? { text: l.translateToString(false), wrapped: l.isWrapped } : null; },
401
403
  buf.length, term.cols, buf.viewportY + cell.row, cell.col
402
- ) : null;
404
+ ) || oscUrlAtCell(term, buf.viewportY + cell.row, cell.col)) : null;
403
405
  // Diagnostics for "clicking a link in a tile does nothing" reports:
404
406
  // localStorage.hay_debug_links=1 prints what the click resolved to.
405
407
  try {
@@ -525,6 +527,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
525
527
  fontFamily: monoStack,
526
528
  cursorBlink: false,
527
529
  disableStdin: true,
530
+ // OSC 8 hyperlinks (Codex's markdown links show a title, the URL rides
531
+ // underneath): without a handler xterm throws up a confirm() dialog,
532
+ // which read as "the link does nothing". Open it like any other link.
533
+ linkHandler: {
534
+ activate: (event, uri) => {
535
+ event.preventDefault();
536
+ lastLinkOpenAtRef.current = Date.now();
537
+ window.open(uri, "_blank", "noopener");
538
+ }
539
+ },
528
540
  theme: theme as never
529
541
  });
530
542
  const fit = new FitAddon();
@@ -12,6 +12,32 @@
12
12
 
13
13
  export type CellLine = { text: string; wrapped: boolean };
14
14
 
15
+ /**
16
+ * The OSC 8 hyperlink under a cell, if the app printed one. Codex renders a
17
+ * markdown link as its TITLE text carrying the URL as an OSC 8 target —
18
+ * "proposed next experiments" on screen, the hop view URL underneath — so
19
+ * there is no URL text for the regex-based addon or urlAtCell to find.
20
+ * xterm keeps the target on the cell (its link id) and in its link service;
21
+ * neither is public API, so this reads them guardedly and returns null on
22
+ * any other xterm. http(s) only: a file:// or custom-scheme target is not
23
+ * something a click in a browser can open.
24
+ */
25
+ export const oscUrlAtCell = (term: unknown, bufferRow: number, col: number): string | null => {
26
+ try {
27
+ const t = term as {
28
+ buffer: { active: { getLine: (r: number) => { getCell: (c: number) => unknown } | undefined } };
29
+ _core?: { _oscLinkService?: { getLinkData: (id: number) => { uri?: string } | undefined } };
30
+ };
31
+ const cell = t.buffer.active.getLine(bufferRow)?.getCell(col) as { extended?: { urlId?: number } } | undefined;
32
+ const id = cell?.extended?.urlId;
33
+ if (!id) return null;
34
+ const uri = t._core?._oscLinkService?.getLinkData(id)?.uri;
35
+ return uri && /^https?:\/\//i.test(uri) ? uri : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+
15
41
  /**
16
42
  * The (row, col) under a viewport point. With the DOM renderer each row is
17
43
  * an element, so the row comes from whichever one contains the point —
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { urlAtCell } from "../src/utils/urlAtCell";
2
+ import { oscUrlAtCell, urlAtCell } from "../src/utils/urlAtCell";
3
3
 
4
4
  const COLS = 42;
5
5
  const pad = (s: string) => s.padEnd(COLS, " ");
@@ -48,3 +48,20 @@ describe("urlAtCell", () => {
48
48
  expect(urlAtCell(b.get, b.length, COLS, 0, 3)).toBeNull();
49
49
  });
50
50
  });
51
+
52
+ describe("oscUrlAtCell — the OSC 8 target under a title (Codex's markdown links)", () => {
53
+ const fakeTerm = (urlId: number | undefined, uri: string | undefined) => ({
54
+ buffer: { active: { getLine: (r: number) => (r === 3 ? { getCell: (c: number) => (c === 5 ? { extended: { urlId } } : {}) } : undefined) } },
55
+ _core: { _oscLinkService: { getLinkData: (id: number) => (id === 7 ? { uri } : undefined) } }
56
+ });
57
+ it("resolves the link's URL from the cell's link id", () => {
58
+ expect(oscUrlAtCell(fakeTerm(7, "https://hop.zhoulab.io/view/s_1/plan.html/inline"), 3, 5)).toBe("https://hop.zhoulab.io/view/s_1/plan.html/inline");
59
+ });
60
+ it("nothing for a plain cell, another row, a non-http target, or an xterm without the internals", () => {
61
+ expect(oscUrlAtCell(fakeTerm(7, "https://x.test/"), 3, 6)).toBeNull();
62
+ expect(oscUrlAtCell(fakeTerm(7, "https://x.test/"), 2, 5)).toBeNull();
63
+ expect(oscUrlAtCell(fakeTerm(undefined, "https://x.test/"), 3, 5)).toBeNull();
64
+ expect(oscUrlAtCell(fakeTerm(7, "file:///etc/hosts"), 3, 5)).toBeNull();
65
+ expect(oscUrlAtCell({ buffer: { active: { getLine: () => ({ getCell: () => ({ extended: { urlId: 7 } }) }) } } }, 3, 5)).toBeNull();
66
+ });
67
+ });
package/hop CHANGED
@@ -4788,7 +4788,7 @@ function buildSessionEntries(haySessions, opts = {}) {
4788
4788
  createdAt: config?.createdAt,
4789
4789
  createdBy: effective?.createdBy || 'user',
4790
4790
  agentPermitted: config?.agentPermitted === true,
4791
- agent: sessionAgentKind(s.name),
4791
+ agent: sessionAgentKind(name),
4792
4792
  localCliCount: clientSummary.localCliCount,
4793
4793
  hasLocalCli: clientSummary.hasLocalCli,
4794
4794
  ...getSessionSeenInfo(name)
@@ -17967,7 +17967,7 @@ async function main() {
17967
17967
  }
17968
17968
  // Name the session it was filed under, so a wrong one is visible at
17969
17969
  // once (an inherited HOP_SESSION can belong to another session).
17970
- console.log(`Filed under ${getSessionDisplayName(session) || session}${wantSession ? '' : ' (from HOP_SESSION; pass --session <name> to file elsewhere)'}.`);
17970
+ console.log(`Filed under ${getSessionDisplayName(caller) || caller}.`);
17971
17971
  // Same one-bell convention as `hop view` — the human is usually
17972
17972
  // waiting on this, and the link scrolls away unannounced otherwise.
17973
17973
  process.stdout.write('\x07');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.70",
3
+ "version": "0.11.71",
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": {
@@ -80,6 +80,21 @@ if (HOP_HOME.startsWith(os.tmpdir())) {
80
80
  }, 30000).unref();
81
81
  }
82
82
  const BUFFER_DIR = path.join(HOP_HOME, 'session-buffers');
83
+ // The last viewer's terminal colors, kept across host restarts: a room that
84
+ // starts headless answers an app's "what's your background?" (OSC 10/11)
85
+ // with these, so Codex/Claude theme themselves light or dark to match what
86
+ // the user actually looks at — restored rooms included.
87
+ const VIEWER_COLORS_FILE = path.join(HOP_HOME, '.hay-viewer-colors');
88
+ function loadViewerColors(hay) {
89
+ try {
90
+ const saved = JSON.parse(fs.readFileSync(VIEWER_COLORS_FILE, 'utf8'));
91
+ if (typeof hay.setDefaultViewerColors === 'function') hay.setDefaultViewerColors(saved.bg, saved.fg);
92
+ } catch (e) { /* none yet: the dark default */ }
93
+ }
94
+ function rememberViewerColors(hay, bg, fg) {
95
+ if (typeof hay.setDefaultViewerColors !== 'function') return;
96
+ if (!hay.setDefaultViewerColors(bg, fg)) return;
97
+ try { fs.writeFileSync(VIEWER_COLORS_FILE, JSON.stringify(hay.getDefaultViewerColors())); } catch (e) { /* best effort */ }
83
98
  const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
84
99
 
85
100
  // Scrub Claude Code SESSION markers from our environment before any PTY is
@@ -220,6 +235,7 @@ function normalizeEnv(rawEnv) {
220
235
  async function main() {
221
236
  const libPath = path.join(__dirname, '..', 'hay', 'apps', 'server', 'dist', 'lib.js');
222
237
  const hay = await import(pathToFileURL(libPath));
238
+ loadViewerColors(hay);
223
239
  const rooms = new hay.RoomManager(hay.createPty);
224
240
  const server = http.createServer(async (req, res) => {
225
241
  const reqUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
@@ -439,6 +455,7 @@ async function main() {
439
455
  const clientFg = hex(wsUrl.searchParams.get('fg'));
440
456
  if (clientBg && typeof room.setClientColors === 'function') {
441
457
  room.setClientColors(clientBg, clientFg);
458
+ rememberViewerColors(hay, clientBg, clientFg);
442
459
  }
443
460
  // Tab identity forwarded by the daemon: the room evicts a same-key
444
461
  // predecessor on attach, so a phone's reconnect replaces its own