hop2 0.11.58 → 0.11.59

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.
@@ -34,7 +34,7 @@ import { getHopState, sleep } from "../hop-demo-lib.mjs";
34
34
  import { chromium, devices } from "../../hay/node_modules/playwright/index.mjs";
35
35
  import { NATIVE_CSS_INIT } from "./native-css.mjs";
36
36
  import {
37
- CHROME, FFMPEG, OUT, VID_TMP, ALLOWED, SCREEN_FORBIDDEN, TERMINALS_PATH, REPORT_HTML, REPO_ROOT, WIPE, loadTerminals
37
+ CHROME, FFMPEG, OUT, VID_TMP, ALLOWED, SCREEN_FORBIDDEN, TERMINALS_PATH, REPORT_HTML, REPO_ROOT, WIPE, castName, loadTerminals
38
38
  } from "./capture-env.mjs";
39
39
 
40
40
  const state = getHopState();
@@ -979,6 +979,76 @@ try {
979
979
  await sleep(3200);
980
980
  await saveVideo(page, "16-drop", trimFor(t0, marker));
981
981
 
982
+ } else if (clip === "08-math-hover") {
983
+ // LaTeX in the output renders on hover: the pointer rests on a formula,
984
+ // the KaTeX tooltip appears, a click pins it, the pointer moves on to a
985
+ // second formula. Full screen, on Lyra, from a wiped prompt.
986
+ //
987
+ // Size discipline: typing through the API makes the API client the size
988
+ // owner, at ITS size — the page would then follow that grid scaled, and
989
+ // xterm maps the mouse through unscaled cell metrics, so a hover misses.
990
+ // So the page claims first (a click), the API client is resized to the
991
+ // page's grid, and only then does the API type: the PTY never changes
992
+ // size again and the page renders 1:1.
993
+ const lyra = requireTerminalId(castName("Lyra"));
994
+ const key = terminals[castName("Lyra")]?.sessionName || castName("Lyra");
995
+ const authH = { Authorization: `Bearer ${state.sessionSecret}` };
996
+ const grid = async () => {
997
+ const sc = await (await fetch(`${state.localUrl}/api/sessions/screen?name=${encodeURIComponent(key)}`, { headers: authH })).json();
998
+ return sc;
999
+ };
1000
+ const { page, t0 } = await newRecordedPage(browser, {
1001
+ css: "try{localStorage.removeItem('hay_pane_tree');localStorage.setItem('hay_view_mode','fit');localStorage.setItem('hay_theme','dark');}catch(e){}"
1002
+ });
1003
+ await gotoSession(page, castName("Lyra"));
1004
+ await sleep(2200);
1005
+ await page.mouse.click(960, 700); // the page takes the size
1006
+ await sleep(1500);
1007
+ const g0 = await grid();
1008
+ await apiResize(lyra, g0.cols, g0.rows); // the API client agrees with it
1009
+ await sleep(600);
1010
+ await apiWrite(lyra, WIPE + "; clear\r");
1011
+ await sleep(1200);
1012
+ await assertDark(page, "08-math-hover");
1013
+ const marker = Date.now();
1014
+ await sleep(900);
1015
+ const line = "printf '%s\\n' 'Closed form: $x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$ and the series \\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6} converge.'";
1016
+ await typeSlow(lyra, line, 22);
1017
+ await sleep(300); await apiWrite(lyra, "\r");
1018
+ await sleep(1400);
1019
+ const strip = (t) => (t || "").replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1bc|\r/g, "");
1020
+ let sc = null, rows = [], row = -1, col = -1, col2 = -1;
1021
+ for (let attempt = 0; attempt < 16 && row < 0; attempt++) {
1022
+ sc = await grid();
1023
+ rows = strip(sc.data || "").split("\n");
1024
+ for (let i = 0; i < rows.length; i++) {
1025
+ const c = rows[i].indexOf("\\frac");
1026
+ if (c >= 0 && !/printf/.test(rows[i])) { row = i; col = c + 2; col2 = rows[i].indexOf("\\sum") + 2; break; }
1027
+ }
1028
+ if (row < 0) await sleep(500);
1029
+ }
1030
+ if (row < 0) throw new Error("ABORT 08: formula not on screen — rows: " + JSON.stringify(rows.filter((r) => r.trim()).slice(0, 4)));
1031
+ const g = await page.evaluate(() => { const s = document.querySelector(".xterm-screen").getBoundingClientRect(); return { left: s.left, top: s.top, w: s.width, h: s.height }; });
1032
+ const cw = g.w / sc.cols, ch = g.h / sc.rows;
1033
+ const x1 = g.left + (col + 0.5) * cw, x2 = g.left + (col2 + 0.5) * cw, y = g.top + (row + 0.5) * ch;
1034
+ console.error(`08: grid ${sc.cols}x${sc.rows} screen ${g.w.toFixed(0)}x${g.h.toFixed(0)} → row ${row} at y ${y.toFixed(0)}, cols ${col}/${col2}`);
1035
+ await page.mouse.move(x1 - 120, y + 60, { steps: 10 });
1036
+ await sleep(500);
1037
+ await page.mouse.move(x1, y, { steps: 18 }); // the tooltip appears
1038
+ await sleep(900);
1039
+ console.error("08: tooltip visible:", await page.evaluate(() => { const t = document.querySelector(".math-tip"); return !!t && !t.hidden; }));
1040
+ await page.screenshot({ path: path.join(VID_TMP, "08-hover-check.png") });
1041
+ await sleep(1700);
1042
+ await page.mouse.down(); await page.mouse.up(); // pin it
1043
+ await sleep(1800);
1044
+ await page.keyboard.press("Escape");
1045
+ await sleep(400);
1046
+ await page.mouse.move(x2, y, { steps: 24 }); // the second formula
1047
+ await sleep(2800);
1048
+ await page.mouse.move(x2, y + 200, { steps: 12 });
1049
+ await sleep(1200);
1050
+ await saveVideo(page, "08-math-hover", trimFor(t0, marker));
1051
+
982
1052
  } else if (clip === "debug-sheet") {
983
1053
  // Diagnostic: open Aurora's card menu on the wall, keep a frame, list
984
1054
  // every visible control. No recording kept.
@@ -1,4 +1,13 @@
1
- # Product video v3 — what shipped since August
1
+ # Product video v3 — footage for the Remotion master
2
+
3
+ **The production video lives in `~/Code/hop-video`** (a Remotion project: the
4
+ July master `out/hop_master_v2*.mp4`, storyboard in `STORYBOARD.md`). This
5
+ directory is the *capture rig* that films the footage it uses. The
6
+ `assemble-video.mjs` cut described below is a rough stitch for review, not
7
+ the product video — the v3 master is rendered from Remotion
8
+ (`npm run render:v3 && npm run mix:v3` there) with the clips listed here
9
+ copied into `footage/live/`.
10
+
2
11
 
3
12
  The v2 cut (August) opened on the wall and closed on the phone illustration.
4
13
  Since then the product grew four stories worth filming — a three-choice
@@ -44,7 +53,7 @@ card appears on the wall while codex starts reading the transcript. The
44
53
  illustration that follows explains what just happened (fork vs hand-off,
45
54
  filed beside its source), the way `hero-overview.svg` explains the runtime.
46
55
 
47
- ## Producing it
56
+ ## Producing it (the rough stitch — for review only)
48
57
 
49
58
  ```bash
50
59
  export PATH=/opt/homebrew/bin:$PATH
@@ -44,7 +44,7 @@ import {
44
44
  type PaneNode,
45
45
  type Side
46
46
  } from "./utils/paneTree";
47
- import { ViewsPanel, ViewsPull, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
47
+ import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
48
48
  import type { ViewsSummary } from "./utils/switcherModel";
49
49
 
50
50
  const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
@@ -4069,7 +4069,6 @@ const App = () => {
4069
4069
  const seen = loadViewsSeen();
4070
4070
  return sessions.filter((s) => hasUnseenViews(s.internalName || s.name, s.views?.latestAt, seen)).length;
4071
4071
  }, [sessions, viewsOpen]);
4072
- const totalViews = useMemo(() => sessions.reduce((n, s) => n + (s.views?.count || 0), 0), [sessions]);
4073
4072
 
4074
4073
  const sessionStyle = isMobile
4075
4074
  ? ({ "--mobile-keyboard-height": `${keyboardVisible ? keyboardHeight : 0}px` } as CSSProperties)
@@ -4159,9 +4158,21 @@ const App = () => {
4159
4158
  onClick={() => setViewsOpen({ session: session.room, dock: true })}
4160
4159
  >
4161
4160
  Views
4162
- {unseenViewSessions > 0
4163
- ? <span className="views-entry-count fresh" aria-label={`New views in ${unseenViewSessions} session${unseenViewSessions === 1 ? "" : "s"}`}>{unseenViewSessions} new</span>
4164
- : totalViews > 0 ? <span className="views-entry-count">{totalViews}</span> : null}
4161
+ {(() => {
4162
+ // This session's own results — the entry is per session, as
4163
+ // the panel it opens is. Fresh when it published since you
4164
+ // last looked; otherwise how many it has.
4165
+ // session.room is whatever the URL carried — the display
4166
+ // name on a directly-loaded page, the internal one after a
4167
+ // switch — so match either, like the panel does.
4168
+ const lc = session.room.toLowerCase();
4169
+ const mine = sessions.find((x) => (x.internalName || "").toLowerCase() === lc
4170
+ || (x.name || "").toLowerCase() === lc || (x.displayName || "").toLowerCase() === lc);
4171
+ const n = mine?.views?.count || 0;
4172
+ const fresh = !!mine && n > 0 && hasUnseenViews(mine.internalName || mine.name, mine.views?.latestAt, loadViewsSeen());
4173
+ return fresh ? <span className="views-entry-count fresh" aria-label="New views">{n} new</span>
4174
+ : n > 0 ? <span className="views-entry-count">{n}</span> : null;
4175
+ })()}
4165
4176
  </button>
4166
4177
  <button
4167
4178
  type="button"
@@ -4998,14 +5009,6 @@ const App = () => {
4998
5009
  {toast && <div className="terminal-toast" role="status" aria-live="polite">{toast}</div>}
4999
5010
  </main>
5000
5011
  )}
5001
- {/* The drawer pull on the right edge: the front door to Views on every
5002
- desktop surface — the wall, the hub and a full-screen session — and
5003
- the one place a new result announces itself in large type. Scoped to
5004
- the session you are in; fleet-wide from the wall and the hub. */}
5005
- {!isMobile && !viewsOpen && (!!session || isEmbeddedInHop()) && (
5006
- <ViewsPull count={totalViews} fresh={unseenViewSessions}
5007
- onOpen={() => setViewsOpen(switcherOpen || !session ? { dock: true } : { session: session.room, dock: true })} />
5008
- )}
5009
5012
  {/* Outside the mode ternary on purpose: Views opens from the hub, from a
5010
5013
  session, and from over the switcher, and it must outlive a switch. */}
5011
5014
  {viewsOpen && (
@@ -2954,7 +2954,7 @@ export const SessionSwitcher = ({
2954
2954
  onClick={(e) => { e.stopPropagation(); onOpenViews(key); }}
2955
2955
  onPointerDown={(e) => e.stopPropagation()}
2956
2956
  >
2957
- ◧ {s.views.count}
2957
+ ◧ <span className="views-word">Views · </span>{s.views.count}
2958
2958
  </button>
2959
2959
  )}
2960
2960
  {waitingOnUser(preview) && (
@@ -18,8 +18,8 @@ import { relativeTime } from "../utils/switcherModel";
18
18
  // - DOCK (the default on a desk-sized window): a right-side drawer with NO
19
19
  // backdrop, so the wall or the terminal beside it stays visible, live and
20
20
  // INTERACTIVE — you read the plot while the session that produced it keeps
21
- // streaming, and you can still type. Opened from the ◧ Views pull on the
22
- // right edge, the topbar, a card's ◧ chip, or ⌘⇧V.
21
+ // streaming, and you can still type. Per session: opened from the session's
22
+ // own Views button, a card's ◧ chip, or ⌘⇧V.
23
23
  // - MODAL (windows too narrow for a drawer): the same reader, centred, with
24
24
  // the index beside the page.
25
25
  //
@@ -200,15 +200,14 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
200
200
  });
201
201
  const panelRef = useRef<HTMLDivElement | null>(null);
202
202
  // The index (the list of everything published) is a companion to the
203
- // reader, not a mode of its own. BESIDE the page where there is room, it
204
- // stays open unless you hide it (remembered). OVER the page — a narrow
205
- // dock — it is a sheet you pull when you want it and never on open, so
206
- // the drawer always lands on the result itself.
203
+ // reader, not a mode of its own: a sheet over the page in a narrow dock, a
204
+ // rail beside it where there is room — either way only when you ask.
205
+ // Hidden until asked for, everywhere: the page is what you opened Views
206
+ // to see. Beside the page it stays as you left it (remembered).
207
207
  const [railOpen, setRailOpen] = useState<boolean>(() => {
208
- try { return localStorage.getItem(RAIL_KEY) !== "0"; } catch { return true; }
208
+ try { return localStorage.getItem(RAIL_KEY) === "1"; } catch { return false; }
209
209
  });
210
210
  const [indexOpen, setIndexOpen] = useState(false);
211
- const widened = useRef(false);
212
211
  const now = Date.now();
213
212
 
214
213
  // The scope arrives as whatever identifier the opener HAD — the URL's
@@ -281,15 +280,6 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
281
280
  // eslint-disable-next-line react-hooks/exhaustive-deps
282
281
  }, [items, flatRows]);
283
282
 
284
- // A session that has published nothing yet is not a dead end: widen to the
285
- // fleet once, automatically, and let the scope toggle say so. Once only —
286
- // a reader who narrows back to the session meant it.
287
- useEffect(() => {
288
- if (!items || widened.current || !resolvedSession || showAll) return;
289
- if (flatRows.length === 0 && items.length > 0) { widened.current = true; setShowAll(true); }
290
- // eslint-disable-next-line react-hooks/exhaustive-deps
291
- }, [items, flatRows, resolvedSession, showAll]);
292
-
293
283
  const at = preview ? flatRows.findIndex((r) => r.path === preview.path) : -1;
294
284
  const step = (dir: 1 | -1) => {
295
285
  if (!flatRows.length) return;
@@ -697,22 +687,3 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
697
687
  </>
698
688
  );
699
689
  };
700
-
701
- /**
702
- * The drawer pull: a tab on the right edge of the page that opens Views. It
703
- * is the affordance a 32px icon in a toolbar never was — big enough to find
704
- * without looking, and it wears the count so a new result is visible from
705
- * across the room.
706
- */
707
- export const ViewsPull = ({ count, fresh, onOpen }: { count: number; fresh: number; onOpen: () => void }) => (
708
- <button type="button"
709
- className={"views-pull" + (fresh > 0 ? " fresh" : "")}
710
- aria-label={`Views: ${count} published${fresh > 0 ? `, new in ${fresh} session${fresh === 1 ? "" : "s"}` : ""}`}
711
- title="Results agents published with hop view (⌘⇧V)"
712
- onClick={onOpen}>
713
- <span className="views-pull-glyph" aria-hidden="true">◧</span>
714
- <span className="views-pull-label">Views</span>
715
- {fresh > 0 ? <span className="views-pull-count fresh">{fresh} new</span>
716
- : count > 0 ? <span className="views-pull-count">{count}</span> : null}
717
- </button>
718
- );
@@ -4670,38 +4670,6 @@ body.pane-dragging { user-select: none; }
4670
4670
  width: 5px; height: 5px; border-radius: 3px;
4671
4671
  background: var(--accent, #7c5cff);
4672
4672
  }
4673
- /* The drawer pull: a tab on the right edge, mid-height, above the wall
4674
- (600) and its sheets (620), below the Views panel (730) it opens. Big by
4675
- design — this is the front door to Views, not a toolbar icon. */
4676
- .views-pull {
4677
- position: fixed;
4678
- right: 0; top: 50%;
4679
- transform: translateY(-50%);
4680
- z-index: 700;
4681
- display: flex; flex-direction: column; align-items: center; gap: 6px;
4682
- width: 44px; padding: 14px 0 12px;
4683
- border: 1px solid var(--line); border-right: none;
4684
- border-radius: 12px 0 0 12px;
4685
- background: var(--panel);
4686
- color: var(--ink-muted);
4687
- box-shadow: -6px 0 22px rgba(0, 0, 0, 0.22);
4688
- cursor: pointer;
4689
- transition: width 0.12s ease, color 0.12s ease;
4690
- }
4691
- .views-pull:hover { color: var(--ink); border-color: var(--accent, #7c5cff); width: 48px; }
4692
- .views-pull.fresh { color: var(--ink); border-color: var(--accent, #7c5cff); }
4693
- .views-pull-glyph { font-size: 18px; line-height: 1; color: var(--accent, #7c5cff); }
4694
- .views-pull-label {
4695
- writing-mode: vertical-rl; transform: rotate(180deg);
4696
- font-size: 12px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
4697
- }
4698
- .views-pull-count {
4699
- font-family: var(--font-mono); font-size: 10px; font-weight: 700;
4700
- padding: 2px 5px; border-radius: 6px;
4701
- background: var(--bg-alt); color: var(--ink-muted);
4702
- writing-mode: vertical-rl; transform: rotate(180deg);
4703
- }
4704
- .views-pull-count.fresh { background: var(--accent, #7c5cff); color: #fff; }
4705
4673
  /* Labelled entry points beat a 16px glyph: the wall's header and the
4706
4674
  topbar both say the word, and wear the count. */
4707
4675
  .views-entry-count {
@@ -4716,12 +4684,24 @@ body.pane-dragging { user-select: none; }
4716
4684
  an inline dot would shove the icon off-centre, so it rides the corner. */
4717
4685
  .switcher-action { position: relative; }
4718
4686
  .switcher-action .views-chip-dot { position: absolute; top: 5px; right: 5px; }
4687
+ /* A session's results are worth a real button on its card, not an 8px
4688
+ chip: the word, the count, a hit target a thumb can find. */
4719
4689
  .switcher-chip.views {
4720
- border: none;
4690
+ border: 1px solid var(--line);
4721
4691
  cursor: pointer;
4722
4692
  font-family: inherit;
4723
4693
  line-height: 1.2;
4694
+ font-size: 10.5px;
4695
+ padding: 3px 8px;
4696
+ border-radius: 7px;
4697
+ color: var(--ink);
4698
+ background: var(--panel);
4699
+ white-space: nowrap;
4724
4700
  }
4701
+ .switcher-chip.views:hover { border-color: var(--accent, #7c5cff); }
4702
+ /* Dense zooms have no room for the word beside the name — the name owns
4703
+ the row (same rule as the inline actions); the glyph and count stay. */
4704
+ .switcher-overlay.tile-dense .switcher-chip.views .views-word { display: none; }
4725
4705
  .switcher-chip.views.fresh {
4726
4706
  background: color-mix(in srgb, var(--accent, #7c5cff) 22%, transparent);
4727
4707
  color: var(--accent, #7c5cff);
@@ -1,6 +1,6 @@
1
1
  import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { ViewsPanel, ViewsPull, hasUnseenViews, loadViewsSeen } from "../src/components/ViewsPanel";
3
+ import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "../src/components/ViewsPanel";
4
4
  import { SessionSwitcher } from "../src/components/SessionSwitcher";
5
5
  import type { SwitcherSession } from "../src/utils/switcherModel";
6
6
 
@@ -16,7 +16,10 @@ let store: Record<string, string> = {};
16
16
  beforeEach(() => {
17
17
  // jsdom's own localStorage is only partially implemented here, and the
18
18
  // seen-markers are the whole point of these cases.
19
- store = {};
19
+ // The index rail is hidden until asked and the choice is remembered; most
20
+ // cases below read rows from it, so they start with it open, the way a
21
+ // reader who opened it once would. The reader-first case clears this.
22
+ store = { hop_views_rail: "1" };
20
23
  vi.stubGlobal("localStorage", {
21
24
  getItem: (key: string) => (key in store ? store[key] : null),
22
25
  setItem: (key: string, value: string) => { store[key] = value; },
@@ -57,8 +60,9 @@ describe("ViewsPanel", () => {
57
60
 
58
61
  it("opens STRAIGHT INTO the newest result; a row click switches the page; the row stays a real link", async () => {
59
62
  const onClose = vi.fn();
63
+ delete store.hop_views_rail; // a first-time reader: nothing remembered
60
64
  render(<ViewsPanel onClose={onClose} />);
61
- await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
65
+ await waitFor(() => expect(document.querySelector("iframe.views-frame")).toBeTruthy());
62
66
  // jsdom's window is 1024 wide — the desk case, where the page exists.
63
67
  // No click needed: the newest item is already the page.
64
68
  let frame = document.querySelector("iframe.views-frame") as HTMLIFrameElement;
@@ -70,7 +74,10 @@ describe("ViewsPanel", () => {
70
74
  // browser's PDF viewer, which is half of what the pane is for.
71
75
  expect(frame.hasAttribute("sandbox")).toBe(false);
72
76
  expect(screen.getByText("1 / 3")).toBeTruthy();
73
- // The index sits beside the page; a plain click on a row turns the page.
77
+ // The index is not shown until asked — the page is the point. ☰ opens
78
+ // it beside the page; a plain click on a row turns the page.
79
+ expect(document.querySelector(".views-list")).toBeNull();
80
+ fireEvent.click(screen.getByRole("button", { name: "Show the index" }));
74
81
  const row = screen.getByText("views-test.html").closest("a") as HTMLAnchorElement;
75
82
  expect(row.getAttribute("target")).toBe("_blank");
76
83
  fireEvent.click(row);
@@ -123,24 +130,11 @@ describe("ViewsPanel", () => {
123
130
  expect(onClose).toHaveBeenCalled();
124
131
  });
125
132
 
126
- it("a session that has published nothing widens to the fleet by itself", async () => {
133
+ it("a session that has published nothing says so — it never widens by itself", async () => {
127
134
  render(<ViewsPanel session="Nowhere" onClose={() => {}} />);
128
- await waitFor(() => expect(document.querySelector("iframe.views-frame")).toBeTruthy());
129
- expect(screen.getByRole("tab", { name: "all" }).getAttribute("aria-selected")).toBe("true");
130
- expect(screen.getByText("1 / 3")).toBeTruthy();
131
- });
132
-
133
- it("the drawer pull says the word and wears the count", () => {
134
- const onOpen = vi.fn();
135
- const { rerender } = render(<ViewsPull count={7} fresh={0} onOpen={onOpen} />);
136
- const pull = screen.getByRole("button", { name: "Views: 7 published" });
137
- expect(pull.textContent).toContain("Views");
138
- expect(pull.textContent).toContain("7");
139
- fireEvent.click(pull);
140
- expect(onOpen).toHaveBeenCalled();
141
- rerender(<ViewsPull count={7} fresh={2} onOpen={onOpen} />);
142
- expect(screen.getByRole("button", { name: "Views: 7 published, new in 2 sessions" }).className).toContain("fresh");
143
- expect(screen.getByText("2 new")).toBeTruthy();
135
+ await waitFor(() => expect(screen.getByText(/Nothing published yet by this session/)).toBeTruthy());
136
+ expect(screen.getByRole("tab", { name: "all" }).getAttribute("aria-selected")).toBe("false");
137
+ expect(document.querySelector("iframe.views-frame")).toBeNull();
144
138
  });
145
139
 
146
140
  it("scoped open can widen to the fleet without reopening", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.58",
3
+ "version": "0.11.59",
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": {