hop2 0.11.8 → 0.11.10
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/demo/capture/capture.mjs +49 -2
- package/demo/product-video-v2.md +64 -0
- package/docs/hero-mobile.svg +105 -0
- package/hay/CHANGELOG.md +3 -0
- package/hay/apps/server/src/rooms.ts +108 -19
- package/hay/apps/server/test/rooms.test.ts +87 -0
- package/hay/apps/web/src/App.tsx +34 -2
- package/hay/apps/web/src/components/SessionSwitcher.tsx +4 -0
- package/hay/apps/web/src/utils/scrollFlywheel.ts +24 -5
- package/hay-web/assets/{index-BJnLaQd5.css → index-D4EXRUpb.css} +1 -1
- package/hay-web/assets/{index-ClpDI5hp.js → index-DQMXebVK.js} +16 -16
- package/hay-web/assets/index-DQMXebVK.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +123 -6
- package/package.json +1 -1
- package/scripts/salvage-and-restart-host.js +4 -4
- package/skills/hop-session/SKILL.md +17 -1
- package/hay-web/assets/index-ClpDI5hp.js.map +0 -1
package/demo/capture/capture.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Sanitized live-footage capture driver for the hop product video (dark theme).
|
|
2
2
|
// Usage: node demo/capture/capture.mjs <clip> where clip in:
|
|
3
|
-
// 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
|
|
3
|
+
// 00-wall | 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
|
|
4
4
|
// 04-phone-switcher | 05-presence | 06-math | 07-theme
|
|
5
5
|
//
|
|
6
6
|
// Run demo/capture/setup-sessions.mjs and demo/capture/spawn-aurora.mjs first
|
|
@@ -112,7 +112,12 @@ async function sanitizeContext(context) {
|
|
|
112
112
|
const resp = await route.fetch();
|
|
113
113
|
let json;
|
|
114
114
|
try { json = await resp.json(); } catch { return await route.fulfill({ response: resp }); }
|
|
115
|
-
json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name))
|
|
115
|
+
json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name))
|
|
116
|
+
// The rig creates its sessions through the terminal API, which the
|
|
117
|
+
// origin classifier (correctly) marks agent-created — but the wall
|
|
118
|
+
// defaults to the USER scope, so the whole demo cast would be
|
|
119
|
+
// hidden behind the Agent toggle. They PLAY user sessions.
|
|
120
|
+
.map((s) => ({ ...s, createdBy: "user" }));
|
|
116
121
|
json.active = (json.active || []).filter((n) => ALLOWED.includes(n));
|
|
117
122
|
json.starting = (json.starting || []).filter((n) => ALLOWED.includes(n));
|
|
118
123
|
json.aliases = {};
|
|
@@ -598,6 +603,48 @@ try {
|
|
|
598
603
|
await sleep(2400);
|
|
599
604
|
await saveVideo(page, "07-theme", trimFor(t0, marker));
|
|
600
605
|
|
|
606
|
+
} else if (clip === "00-wall") {
|
|
607
|
+
// THE signature shot of the switcher-first era: the wall is the product.
|
|
608
|
+
// Live tiles ticking, folders, the briefing card up top, filter-to-find,
|
|
609
|
+
// click = engage in place, ⌘⏎ = the one deliberate door into full screen.
|
|
610
|
+
// The briefing is staged by ROUTE INTERCEPTION — no file ever touches
|
|
611
|
+
// hay-web/assets, so nothing sanitization-sensitive can leak or linger.
|
|
612
|
+
const { ctx, page, t0 } = await newRecordedPage(browser, {
|
|
613
|
+
css: "try{localStorage.setItem('hay_tile_zoom','6');localStorage.setItem('hay_theme','dark');}catch(e){}"
|
|
614
|
+
});
|
|
615
|
+
await ctx.route("**/assets/digest.json", (route) =>
|
|
616
|
+
route.fulfill({
|
|
617
|
+
contentType: "application/json",
|
|
618
|
+
body: JSON.stringify({
|
|
619
|
+
generated_at: new Date().toISOString(),
|
|
620
|
+
summary: "Nebula finished the ingest refactor; Polaris is mid-benchmark; Aurora wants a review.",
|
|
621
|
+
items: [
|
|
622
|
+
{ session: "Nebula", headline: "Ingest refactor landed — 41s → 9s on the sample day", urgency: "info" },
|
|
623
|
+
{ session: "Polaris", headline: "Benchmark sweep 60% done, no regressions so far", urgency: "info" },
|
|
624
|
+
{ session: "Aurora", headline: "Wants review: error-handling change in the exporter", urgency: "attention" }
|
|
625
|
+
]
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
await page.goto(`${state.localUrl}/s/Lyra2/?view=wall`, { waitUntil: "networkidle", timeout: 30000 });
|
|
630
|
+
await sleep(3500);
|
|
631
|
+
await assertDark(page, "00-wall");
|
|
632
|
+
const marker = Date.now();
|
|
633
|
+
await sleep(2600); // the wall breathing: tiles tick, briefing reads
|
|
634
|
+
// Find-by-typing: the filter narrows the wall live.
|
|
635
|
+
const filterBox = page.locator(".switcher-filter").first();
|
|
636
|
+
await filterBox.click();
|
|
637
|
+
await page.keyboard.type("neb", { delay: 160 });
|
|
638
|
+
await sleep(1600);
|
|
639
|
+
for (let i = 0; i < 3; i++) { await page.keyboard.press("Backspace"); await sleep(120); }
|
|
640
|
+
await sleep(900);
|
|
641
|
+
// Click a session: it engages IN PLACE — the wall never yanks you away.
|
|
642
|
+
await page.locator(".switcher-card", { hasText: "Nebula" }).first().click();
|
|
643
|
+
await sleep(3200);
|
|
644
|
+
// ⌘⏎ — the one deliberate door into full screen.
|
|
645
|
+
await page.keyboard.press("Meta+Enter");
|
|
646
|
+
await sleep(3400);
|
|
647
|
+
await saveVideo(page, "00-wall", trimFor(t0, marker));
|
|
601
648
|
} else if (clip === "01-sessions") {
|
|
602
649
|
const { page, t0 } = await newRecordedPage(browser, {});
|
|
603
650
|
await page.goto(`${state.localUrl}/sessions`, { waitUntil: "networkidle", timeout: 30000 });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Product video v2 — the switcher-first cut
|
|
2
|
+
|
|
3
|
+
The March cut opened on a session *list* and treated full screen as the
|
|
4
|
+
product. That is backwards now: **the wall is the product**, full screen is a
|
|
5
|
+
deliberate mode, and three new stories exist — the briefing, hop-ios, and
|
|
6
|
+
HopBoard dictation. Target: ~60s, captioned, no voiceover.
|
|
7
|
+
|
|
8
|
+
## Shot list
|
|
9
|
+
|
|
10
|
+
Captured with the sanitized rig (`demo/capture/`) unless marked PHONE.
|
|
11
|
+
Dark theme throughout; captions succinct, not salesy.
|
|
12
|
+
|
|
13
|
+
1. **00-wall** (10–13s) — CAPTURED, `demo-output/footage/live/00-wall.webm`
|
|
14
|
+
The wall breathing: live tiles ticking, briefing card up top, filter
|
|
15
|
+
narrows live, click engages a session in place, ⌘⏎ enters full screen.
|
|
16
|
+
Caption: `Every terminal, one wall. Sessions are the home screen.`
|
|
17
|
+
2. **01-briefing** (5s) — crop/zoom of the briefing card reading itself.
|
|
18
|
+
Caption: `A briefing, not a backlog — what happened while you were away.`
|
|
19
|
+
(Staged via route interception in the 00-wall clip; can reuse frames.)
|
|
20
|
+
3. **02-agent-live** (8s) — existing rig clip, still valid: an agent working
|
|
21
|
+
in a session while you watch and type into the same terminal.
|
|
22
|
+
Caption: `Agents work in real terminals. Watch, steer, take over.`
|
|
23
|
+
4. **03-phone-live** (6s) — existing rig clip (mobile web).
|
|
24
|
+
Caption: `The same fleet from your phone.`
|
|
25
|
+
5. **04-mobile** (6s) — `docs/hero-mobile.svg` as a Ken-Burns still
|
|
26
|
+
(slow push-in, left phone then right phone). Covers BOTH hop-ios and
|
|
27
|
+
HopBoard in the house illustration style (same canvas/typography as
|
|
28
|
+
hero-overview.svg). Caption: none needed — the annotations are in
|
|
29
|
+
the frame. UPGRADE PATH: real device footage replaces this still
|
|
30
|
+
whenever it gets filmed; the storyboard slot and timing stay.
|
|
31
|
+
Why not simulator footage: simctl has no tap injection (driving the
|
|
32
|
+
app means running Orion's XCUITest rig inside their repo), system
|
|
33
|
+
dialogs (Apple ID sign-in) land on camera, and HopBoard specifically
|
|
34
|
+
cannot be real there — keyboard-extension + mic pipeline + Whisper on
|
|
35
|
+
the Neural Engine don't function in the simulator, so the 1–3s
|
|
36
|
+
transcription moment would have to be staged. The dictation demo's
|
|
37
|
+
honesty is its point; an illustration is honest, a fake capture isn't.
|
|
38
|
+
7. **06-close** (3s) — wall again, one tile ringing its attention bell,
|
|
39
|
+
cursor moves to it. Caption: `hop — github.com/jzthree/hop`.
|
|
40
|
+
|
|
41
|
+
## Rig notes (what changed since March)
|
|
42
|
+
|
|
43
|
+
- New clip `00-wall` in `capture.mjs`; briefing is staged by intercepting
|
|
44
|
+
`/assets/digest.json` — nothing written to disk, nothing to leak.
|
|
45
|
+
- The rig's sessions are created through the terminal API, so the origin
|
|
46
|
+
classifier marks them agent-created and the wall's default USER scope
|
|
47
|
+
would hide the entire cast — the network filter now presents them as
|
|
48
|
+
user sessions (`createdBy: "user"` rewrite).
|
|
49
|
+
- The legacy `01-sessions` clip targets the old `/sessions` hub page.
|
|
50
|
+
Superseded by `00-wall`; keep the code until the v2 edit is locked.
|
|
51
|
+
|
|
52
|
+
## Phone footage checklist (only you can film these)
|
|
53
|
+
|
|
54
|
+
- iPhone, dark mode, notifications off, screen-record at 60fps.
|
|
55
|
+
- hop-ios: open on the wall, scroll it once, tap a tile, type one short
|
|
56
|
+
line (keyboard feel is the shot), open the briefing.
|
|
57
|
+
- HopBoard: in Notes or a claude session, tap mic key, speak one sentence
|
|
58
|
+
("route the exporter errors into the retry queue"), stop, let the text
|
|
59
|
+
land. Keep the orange mic indicator in frame — it is the privacy story.
|
|
60
|
+
|
|
61
|
+
## Assembly
|
|
62
|
+
|
|
63
|
+
`stitch-demo-video.mjs` concatenates WebM → MP4 as before. Captions get
|
|
64
|
+
burned in at edit time (same as March cut) — keep each on screen ≥2.5s.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="620" viewBox="0 0 980 620" role="img" aria-label="hop on the phone: the native iOS app showing the session wall with a morning briefing and live terminal tiles, and HopBoard, a system-wide keyboard with on-device Whisper dictation — audio never leaves the phone.">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="canvasM" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#16171c"/>
|
|
5
|
+
<stop offset="1" stop-color="#101116"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<filter id="shadowM" x="-20%" y="-20%" width="140%" height="160%">
|
|
8
|
+
<feDropShadow dx="0" dy="6" stdDeviation="14" flood-color="#000000" flood-opacity="0.45"/>
|
|
9
|
+
</filter>
|
|
10
|
+
<clipPath id="phoneA"><rect x="120" y="96" width="250" height="470" rx="28"/></clipPath>
|
|
11
|
+
<clipPath id="phoneB"><rect x="612" y="96" width="250" height="470" rx="28"/></clipPath>
|
|
12
|
+
</defs>
|
|
13
|
+
|
|
14
|
+
<!-- canvas -->
|
|
15
|
+
<rect x="0.5" y="0.5" width="979" height="619" rx="18" fill="url(#canvasM)" stroke="#272831"/>
|
|
16
|
+
|
|
17
|
+
<!-- kicker -->
|
|
18
|
+
<text x="490" y="44" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" letter-spacing="0.14em" fill="#8d909b">THE FLEET IN YOUR POCKET — NATIVE APP, AND A KEYBOARD THAT LISTENS ON-DEVICE</text>
|
|
19
|
+
|
|
20
|
+
<!-- ═══════════ ① hop-ios: the wall, native ═══════════ -->
|
|
21
|
+
<g filter="url(#shadowM)">
|
|
22
|
+
<rect x="120" y="96" width="250" height="470" rx="28" fill="#0b0b0e" stroke="#33343c" stroke-width="2"/>
|
|
23
|
+
</g>
|
|
24
|
+
<g clip-path="url(#phoneA)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
|
|
25
|
+
<!-- dynamic island -->
|
|
26
|
+
<rect x="205" y="106" width="80" height="16" rx="8" fill="#000000"/>
|
|
27
|
+
<!-- app header -->
|
|
28
|
+
<text x="136" y="146" fill="#e6e6eb" font-weight="700" font-size="13">Sessions</text>
|
|
29
|
+
<text x="354" y="146" text-anchor="end" fill="#8d909b" font-size="10">4 live</text>
|
|
30
|
+
<!-- briefing card -->
|
|
31
|
+
<rect x="132" y="158" width="226" height="86" rx="10" fill="#17181f" stroke="#2c2d36"/>
|
|
32
|
+
<text x="142" y="176" font-size="9.5"><tspan fill="#a78bfa" font-weight="700">✦ Briefing</tspan><tspan fill="#8d909b">  7:30am</tspan></text>
|
|
33
|
+
<text x="142" y="192" fill="#c9cad3" font-size="9">Nebula landed the ingest refactor</text>
|
|
34
|
+
<text x="142" y="205" fill="#c9cad3" font-size="9">overnight — 41s → 9s. Polaris is</text>
|
|
35
|
+
<text x="142" y="218" fill="#c9cad3" font-size="9">mid-benchmark, no regressions.</text>
|
|
36
|
+
<text x="142" y="235" font-size="9"><tspan fill="#f0883e">●</tspan><tspan fill="#e6e6eb"> Aurora wants a review</tspan></text>
|
|
37
|
+
<!-- tile: Nebula -->
|
|
38
|
+
<rect x="132" y="254" width="226" height="92" rx="10" fill="#101116" stroke="#2c2d36"/>
|
|
39
|
+
<text x="142" y="270" font-size="9.5"><tspan fill="#28c840">●</tspan><tspan fill="#e6e6eb" font-weight="700"> Nebula</tspan></text>
|
|
40
|
+
<text x="142" y="288" fill="#8d909b" font-size="8.5">✓ tests: 47 passed</text>
|
|
41
|
+
<text x="142" y="301" fill="#8d909b" font-size="8.5">transform src/hooks/usePresence.ts</text>
|
|
42
|
+
<text x="142" y="314" fill="#8d909b" font-size="8.5">128 modules in 296ms</text>
|
|
43
|
+
<text x="142" y="334" font-size="8.5"><tspan fill="#6e7079">❯</tspan><tspan fill="#c9cad3"> </tspan></text>
|
|
44
|
+
<rect x="152" y="326" width="5" height="10" fill="#a78bfa" opacity="0.85"/>
|
|
45
|
+
<!-- tile: Polaris with bell -->
|
|
46
|
+
<rect x="132" y="356" width="226" height="70" rx="10" fill="#101116" stroke="#7c3aed"/>
|
|
47
|
+
<text x="142" y="372" font-size="9.5"><tspan fill="#a78bfa">◉</tspan><tspan fill="#e6e6eb" font-weight="700"> Polaris</tspan><tspan fill="#f0883e"> · needs you</tspan></text>
|
|
48
|
+
<text x="142" y="390" fill="#8d909b" font-size="8.5">rollout wave 3/4 complete</text>
|
|
49
|
+
<text x="142" y="403" fill="#8d909b" font-size="8.5">continue to wave 4/4? (y/n)</text>
|
|
50
|
+
<!-- keyboard hint strip -->
|
|
51
|
+
<rect x="132" y="436" width="226" height="30" rx="8" fill="#17181f" stroke="#2c2d36"/>
|
|
52
|
+
<text x="245" y="455" text-anchor="middle" fill="#8d909b" font-size="9">esc · tab · ctrl · ↑ ↓ · ⌘K</text>
|
|
53
|
+
<!-- home indicator -->
|
|
54
|
+
<rect x="205" y="546" width="80" height="4" rx="2" fill="#33343c"/>
|
|
55
|
+
</g>
|
|
56
|
+
<text x="245" y="592" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" fill="#c9cad3"><tspan fill="#a78bfa" font-weight="700">①</tspan> native app — the wall, the briefing, push when a session rings</text>
|
|
57
|
+
|
|
58
|
+
<!-- connective arrow -->
|
|
59
|
+
<g stroke="#3d3e49" stroke-width="1.5" fill="none">
|
|
60
|
+
<path d="M 386 330 C 450 330 530 330 596 330" stroke-dasharray="4 5"/>
|
|
61
|
+
</g>
|
|
62
|
+
<text x="491" y="318" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" fill="#8d909b">same fleet · same daemon</text>
|
|
63
|
+
|
|
64
|
+
<!-- ═══════════ ② HopBoard: dictation keyboard ═══════════ -->
|
|
65
|
+
<g filter="url(#shadowM)">
|
|
66
|
+
<rect x="612" y="96" width="250" height="470" rx="28" fill="#0b0b0e" stroke="#33343c" stroke-width="2"/>
|
|
67
|
+
</g>
|
|
68
|
+
<g clip-path="url(#phoneB)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
|
|
69
|
+
<rect x="697" y="106" width="80" height="16" rx="8" fill="#000000"/>
|
|
70
|
+
<!-- orange mic indicator -->
|
|
71
|
+
<circle cx="789" cy="114" r="3.5" fill="#ff9f0a"/>
|
|
72
|
+
<!-- a claude session being dictated into -->
|
|
73
|
+
<text x="628" y="150" font-size="9.5"><tspan fill="#28c840">●</tspan><tspan fill="#e6e6eb" font-weight="700"> Aurora</tspan><tspan fill="#8d909b"> · full screen</tspan></text>
|
|
74
|
+
<text x="628" y="172" fill="#8d909b" font-size="8.5">⏺ Exporter errors now surface in</text>
|
|
75
|
+
<text x="628" y="185" fill="#8d909b" font-size="8.5"> the retry queue with backoff.</text>
|
|
76
|
+
<rect x="624" y="200" width="226" height="42" rx="8" fill="#17181f" stroke="#2c2d36"/>
|
|
77
|
+
<text x="634" y="217" font-size="9"><tspan fill="#6e7079">❯</tspan><tspan fill="#e6e6eb"> route the exporter errors into</tspan></text>
|
|
78
|
+
<text x="634" y="231" font-size="9"><tspan fill="#e6e6eb"> the retry queue</tspan></text>
|
|
79
|
+
<rect x="762" y="222" width="5" height="11" fill="#a78bfa" opacity="0.85"/>
|
|
80
|
+
<!-- transcribing pill -->
|
|
81
|
+
<rect x="624" y="252" width="226" height="24" rx="12" fill="#1c1420" stroke="#7c3aed"/>
|
|
82
|
+
<text x="737" y="268" text-anchor="middle" font-size="9"><tspan fill="#a78bfa">●</tspan><tspan fill="#c9cad3"> whisper large-v3-turbo · on-device · 1.4s</tspan></text>
|
|
83
|
+
<!-- keyboard -->
|
|
84
|
+
<rect x="612" y="290" width="250" height="276" fill="#141419"/>
|
|
85
|
+
<g fill="#232430">
|
|
86
|
+
<rect x="624" y="302" width="20" height="30" rx="5"/><rect x="648" y="302" width="20" height="30" rx="5"/><rect x="672" y="302" width="20" height="30" rx="5"/><rect x="696" y="302" width="20" height="30" rx="5"/><rect x="720" y="302" width="20" height="30" rx="5"/><rect x="744" y="302" width="20" height="30" rx="5"/><rect x="768" y="302" width="20" height="30" rx="5"/><rect x="792" y="302" width="20" height="30" rx="5"/><rect x="816" y="302" width="20" height="30" rx="5"/><rect x="838" y="302" width="12" height="30" rx="5"/>
|
|
87
|
+
<rect x="636" y="338" width="20" height="30" rx="5"/><rect x="660" y="338" width="20" height="30" rx="5"/><rect x="684" y="338" width="20" height="30" rx="5"/><rect x="708" y="338" width="20" height="30" rx="5"/><rect x="732" y="338" width="20" height="30" rx="5"/><rect x="756" y="338" width="20" height="30" rx="5"/><rect x="780" y="338" width="20" height="30" rx="5"/><rect x="804" y="338" width="20" height="30" rx="5"/><rect x="828" y="338" width="22" height="30" rx="5"/>
|
|
88
|
+
<rect x="624" y="374" width="30" height="30" rx="5"/><rect x="660" y="374" width="20" height="30" rx="5"/><rect x="684" y="374" width="20" height="30" rx="5"/><rect x="708" y="374" width="20" height="30" rx="5"/><rect x="732" y="374" width="20" height="30" rx="5"/><rect x="756" y="374" width="20" height="30" rx="5"/><rect x="780" y="374" width="20" height="30" rx="5"/><rect x="806" y="374" width="44" height="30" rx="5"/>
|
|
89
|
+
</g>
|
|
90
|
+
<!-- mic key, active -->
|
|
91
|
+
<rect x="624" y="410" width="52" height="34" rx="7" fill="#7c3aed"/>
|
|
92
|
+
<text x="650" y="432" text-anchor="middle" fill="#ffffff" font-size="13">🎙</text>
|
|
93
|
+
<rect x="682" y="410" width="120" height="34" rx="7" fill="#232430"/>
|
|
94
|
+
<text x="742" y="431" text-anchor="middle" fill="#8d909b" font-size="9">space</text>
|
|
95
|
+
<rect x="808" y="410" width="42" height="34" rx="7" fill="#232430"/>
|
|
96
|
+
<text x="829" y="431" text-anchor="middle" fill="#8d909b" font-size="9">⏎</text>
|
|
97
|
+
<!-- waveform -->
|
|
98
|
+
<g stroke="#a78bfa" stroke-width="2" stroke-linecap="round">
|
|
99
|
+
<line x1="662" y1="470" x2="662" y2="482"/><line x1="672" y1="464" x2="672" y2="488"/><line x1="682" y1="458" x2="682" y2="494"/><line x1="692" y1="466" x2="692" y2="486"/><line x1="702" y1="472" x2="702" y2="480"/><line x1="712" y1="462" x2="712" y2="490"/><line x1="722" y1="468" x2="722" y2="484"/><line x1="732" y1="456" x2="732" y2="496"/><line x1="742" y1="466" x2="742" y2="486"/><line x1="752" y1="472" x2="752" y2="480"/><line x1="762" y1="460" x2="762" y2="492"/><line x1="772" y1="468" x2="772" y2="484"/><line x1="782" y1="474" x2="782" y2="478"/><line x1="792" y1="464" x2="792" y2="488"/><line x1="802" y1="470" x2="802" y2="482"/>
|
|
100
|
+
</g>
|
|
101
|
+
<text x="737" y="518" text-anchor="middle" fill="#8d909b" font-size="9">hold to speak · release to insert</text>
|
|
102
|
+
<rect x="697" y="546" width="80" height="4" rx="2" fill="#33343c"/>
|
|
103
|
+
</g>
|
|
104
|
+
<text x="737" y="592" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" fill="#c9cad3"><tspan fill="#a78bfa" font-weight="700">②</tspan> HopBoard — on-device Whisper dictation; audio never leaves the phone</text>
|
|
105
|
+
</svg>
|
package/hay/CHANGELOG.md
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
### Bug Fixes
|
|
34
34
|
|
|
35
|
+
#### Web terminal
|
|
36
|
+
- **Claude screens render completely on attach without a scroll**: Hop now starts its authoritative screen grid when a session enters alternate-screen or mouse-reporting TUI mode, before startup rows can fall out of a bounded replay tail. Full-screen attaches use that serialized screen, and direct snapshot paints re-arm one delayed repaint in case the browser misses the first WebGL frame.
|
|
37
|
+
|
|
35
38
|
#### Session restore
|
|
36
39
|
- **`hop restore` actually restores now (Claude conversations included), from the SessionStart-hook records**: restore built its candidate list from the workspace-scoped session store, which is only populated when a workspace is active — and no workspace is ever activated in normal use, so `restore` found nothing and silently restored zero sessions. It now sources candidates directly from the durable per-session records the SessionStart hook writes (`~/.hop2/claude-sessions/<name>.json` = `{ sessionId, cwd }`), which survive a crash/reboot and carry the exact conversation id. So after a restart, `hop restore` recreates each session in its directory and resumes its Claude conversation with `claude --resume <id>` — no manual capture step. Sessions already live are skipped (idempotent), and `hop restore` now starts the daemon itself if it isn't running, so post-reboot recovery is a single command. `--dry-run` previews the plan. Records are pruned when a session is deleted, so restore never resurrects a session you intentionally closed (and if an unwanted one does come back, deleting it once keeps it gone).
|
|
37
40
|
- **`hopx_agent_turn` no longer runs non-UI input twice**: in `readable_raw` mode (and `auto` resolving to it — i.e. a plain shell or any non-alternate-screen agent), a synchronous turn pre-sent the input and *then* sent it again via the combined send+wait, so a command driven through `hopx_agent_turn` executed twice. The pre-send is now done only for the paths that need it (the `ui` branch and async turns, which return before the combined call); non-UI synchronous turns let the single send+wait do the send. UI and async turns are unchanged.
|
|
@@ -175,6 +175,11 @@ const toPresence = (client: ClientState): PresenceClient => ({
|
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
class ClientState {
|
|
178
|
+
// True from attach until this client's serialized snapshot has been sent.
|
|
179
|
+
// Output broadcasts are withheld meanwhile (see broadcast) and delivered
|
|
180
|
+
// as catch-up appended to the snapshot itself.
|
|
181
|
+
awaitingSnapshot = false;
|
|
182
|
+
snapshotCatchUp: string[] = [];
|
|
178
183
|
id: string;
|
|
179
184
|
name: string;
|
|
180
185
|
color: string;
|
|
@@ -257,10 +262,15 @@ export class Room extends EventEmitter {
|
|
|
257
262
|
// connect created the room, so its hello carries created=true.
|
|
258
263
|
private hasHadClient = false;
|
|
259
264
|
// Authoritative screen state for serialized attach (see screenGrid.ts).
|
|
260
|
-
//
|
|
261
|
-
//
|
|
265
|
+
// Start it as soon as a TUI advertises alternate-screen or mouse mode, so
|
|
266
|
+
// rows painted once at startup cannot age out of a bounded replay tail.
|
|
267
|
+
// Plain shells stay lazy and pay no grid cost until somebody attaches.
|
|
262
268
|
private grid: ScreenGrid | null = null;
|
|
263
269
|
private gridHydrated = false;
|
|
270
|
+
private gridStartPending = false;
|
|
271
|
+
private gridUnavailable = false;
|
|
272
|
+
private gridPendingSeed: string[] | null = null;
|
|
273
|
+
private gridPendingSeedBytes = 0;
|
|
264
274
|
|
|
265
275
|
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
|
|
266
276
|
super();
|
|
@@ -293,6 +303,7 @@ export class Room extends EventEmitter {
|
|
|
293
303
|
this.lastActivityAt = now();
|
|
294
304
|
this.appendOutput(data);
|
|
295
305
|
this.updateTerminalState(data);
|
|
306
|
+
this.startTuiGrid();
|
|
296
307
|
this.answerTerminalQueriesIfHeadless(data);
|
|
297
308
|
this.broadcast({ type: "output", data });
|
|
298
309
|
this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
|
|
@@ -438,9 +449,23 @@ export class Room extends EventEmitter {
|
|
|
438
449
|
// the web full screen's ~384KB request wants the grid's full depth.
|
|
439
450
|
const serializedScrollback = replayRequest !== null && replayRequest <= 131_072 ? 200 : undefined;
|
|
440
451
|
if (gridForAttach) {
|
|
452
|
+
// Everything already in the ring at THIS moment will be in the
|
|
453
|
+
// serialized screen; everything after it will not (grid writes queue
|
|
454
|
+
// behind the serialize marker). Mute output to this client until the
|
|
455
|
+
// snapshot goes out, retaining the exact withheld chunks for catch-up.
|
|
456
|
+
client.awaitingSnapshot = true;
|
|
457
|
+
client.snapshotCatchUp = [];
|
|
441
458
|
gridForAttach.serialize((serialized) => {
|
|
442
|
-
if (this.ended || !this.clients.has(client.id) || !socket.isOpen())
|
|
459
|
+
if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) {
|
|
460
|
+
client.awaitingSnapshot = false;
|
|
461
|
+
client.snapshotCatchUp = [];
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
443
464
|
if (serialized === null) {
|
|
465
|
+
// Raw fallback reads the ring as it stands NOW. Unmute first so
|
|
466
|
+
// nothing new slips into the handoff (raw has no in-band RIS).
|
|
467
|
+
client.awaitingSnapshot = false;
|
|
468
|
+
client.snapshotCatchUp = [];
|
|
444
469
|
sendRawSnapshot();
|
|
445
470
|
wiggle();
|
|
446
471
|
return;
|
|
@@ -453,14 +478,20 @@ export class Room extends EventEmitter {
|
|
|
453
478
|
// read the separate flags — ends up in the app's actual modes.
|
|
454
479
|
const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
455
480
|
+ (this.cursorHidden ? "\x1b[?25l" : "");
|
|
481
|
+
// Catch-up: output that arrived while the snapshot was being
|
|
482
|
+
// serialized. Appended INSIDE the snapshot payload so snapshot and
|
|
483
|
+
// delta land as one atomic parse — no frame between reset and delta.
|
|
484
|
+
const catchUp = client.snapshotCatchUp.join("");
|
|
456
485
|
socket.send(JSON.stringify({
|
|
457
486
|
type: "snapshot",
|
|
458
487
|
// In-band RIS: every client applies the serialized screen onto a
|
|
459
488
|
// clean slate, including ones that don't reset on their own.
|
|
460
|
-
data: "\x1bc" + serialized + modeSeq,
|
|
489
|
+
data: "\x1bc" + serialized + modeSeq + catchUp,
|
|
461
490
|
capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
|
|
462
491
|
...snapshotFlags()
|
|
463
492
|
} satisfies ServerMessage));
|
|
493
|
+
client.awaitingSnapshot = false;
|
|
494
|
+
client.snapshotCatchUp = [];
|
|
464
495
|
// No wiggle: the serialized grid IS the current screen.
|
|
465
496
|
}, serializedScrollback);
|
|
466
497
|
} else {
|
|
@@ -542,16 +573,23 @@ export class Room extends EventEmitter {
|
|
|
542
573
|
|
|
543
574
|
private updateTerminalState(data: string) {
|
|
544
575
|
const combined = this.controlSequenceTail + data;
|
|
576
|
+
const tailLength = this.controlSequenceTail.length;
|
|
577
|
+
const risIndex = combined.lastIndexOf("\x1bc");
|
|
578
|
+
// Only process a RIS that ends in this chunk; one entirely inside the
|
|
579
|
+
// carry tail was already applied on the preceding call. Modes before the
|
|
580
|
+
// last new RIS are reset, while later sequences in the same chunk win.
|
|
581
|
+
const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
|
|
582
|
+
const stateInput = hasNewRis ? combined.slice(risIndex + 2) : combined;
|
|
545
583
|
|
|
546
584
|
// Parse CSI mode sequences (cursor visibility, alternate screen)
|
|
547
585
|
const regex = /\x1b\[\?([0-9;]*)([hl])/g;
|
|
548
586
|
let match: RegExpExecArray | null;
|
|
549
|
-
let nextAlternate = this.alternateScreen;
|
|
550
|
-
let nextCursorHidden = this.cursorHidden;
|
|
587
|
+
let nextAlternate = hasNewRis ? false : this.alternateScreen;
|
|
588
|
+
let nextCursorHidden = hasNewRis ? false : this.cursorHidden;
|
|
551
589
|
|
|
552
|
-
let nextMouseReporting = this.mouseReporting;
|
|
553
|
-
let nextMouseSgr = this.mouseSgr;
|
|
554
|
-
while ((match = regex.exec(
|
|
590
|
+
let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
|
|
591
|
+
let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
|
|
592
|
+
while ((match = regex.exec(stateInput)) !== null) {
|
|
555
593
|
const params = match[1].split(";").filter(Boolean);
|
|
556
594
|
const mode = match[2];
|
|
557
595
|
for (const param of params) {
|
|
@@ -571,10 +609,10 @@ export class Room extends EventEmitter {
|
|
|
571
609
|
|
|
572
610
|
// Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
|
|
573
611
|
// CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
|
|
574
|
-
let nextKeyboardEnhanced = this.keyboardEnhanced;
|
|
612
|
+
let nextKeyboardEnhanced = hasNewRis ? false : this.keyboardEnhanced;
|
|
575
613
|
const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
|
|
576
614
|
let kbdMatch: RegExpExecArray | null;
|
|
577
|
-
while ((kbdMatch = kbdRegex.exec(
|
|
615
|
+
while ((kbdMatch = kbdRegex.exec(stateInput)) !== null) {
|
|
578
616
|
if (kbdMatch[1] !== undefined) {
|
|
579
617
|
nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
|
|
580
618
|
} else {
|
|
@@ -923,7 +961,19 @@ export class Room extends EventEmitter {
|
|
|
923
961
|
|
|
924
962
|
private broadcast(message: ServerMessage) {
|
|
925
963
|
const payload = JSON.stringify(message);
|
|
964
|
+
const isOutput = (message as { type?: string }).type === "output";
|
|
926
965
|
for (const client of this.clients.values()) {
|
|
966
|
+
// A client whose serialized snapshot is still being prepared must not
|
|
967
|
+
// receive live output yet: the snapshot opens with an in-band RIS, so
|
|
968
|
+
// output sent before it would be ERASED by it — and the serialized
|
|
969
|
+
// grid does not contain those bytes either (they queued behind the
|
|
970
|
+
// serialize marker). That silent loss was 'the screen is stale until
|
|
971
|
+
// the app repaints' on every attach that raced live output. The
|
|
972
|
+
// withheld bytes are delivered as catch-up appended to the snapshot.
|
|
973
|
+
if (isOutput && client.awaitingSnapshot) {
|
|
974
|
+
client.snapshotCatchUp.push((message as { data: string }).data);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
927
977
|
if (client.socket.isOpen()) {
|
|
928
978
|
client.socket.send(payload);
|
|
929
979
|
}
|
|
@@ -1021,13 +1071,38 @@ export class Room extends EventEmitter {
|
|
|
1021
1071
|
}
|
|
1022
1072
|
}
|
|
1023
1073
|
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1074
|
+
// Incremental TUIs paint some rows once and then update other regions for
|
|
1075
|
+
// hours. Reconstructing their first grid from any bounded tail can therefore
|
|
1076
|
+
// omit cells until the app happens to repaint (for example, on scroll).
|
|
1077
|
+
// Begin tracking at TUI entry; dependency loading is shared and normally
|
|
1078
|
+
// finishes first, but the callback covers a TUI that starts immediately.
|
|
1079
|
+
private startTuiGrid() {
|
|
1080
|
+
if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
|
|
1081
|
+
if (!this.alternateScreen && !this.mouseReporting) return;
|
|
1082
|
+
if (screenGridsAvailable()) {
|
|
1083
|
+
this.gridUnavailable = this.ensureGrid() === null;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
this.gridStartPending = true;
|
|
1087
|
+
this.gridPendingSeed = [this.tailOutput(MAX_BUFFER_SIZE)];
|
|
1088
|
+
this.gridPendingSeedBytes = this.gridPendingSeed[0].length;
|
|
1089
|
+
void loadScreenGridDeps().then((available) => {
|
|
1090
|
+
this.gridStartPending = false;
|
|
1091
|
+
this.gridUnavailable = !available;
|
|
1092
|
+
const seed = this.gridPendingSeed?.join("") ?? "";
|
|
1093
|
+
this.gridPendingSeed = null;
|
|
1094
|
+
this.gridPendingSeedBytes = 0;
|
|
1095
|
+
if (available && !this.ended) {
|
|
1096
|
+
this.gridUnavailable = this.ensureGrid(seed) === null;
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Plain-shell grids remain lazy until the first serialized attach. Creation
|
|
1102
|
+
// hydrates from a bounded ring tail; append-only shell output is safely
|
|
1103
|
+
// reconstructible from that tail, unlike the incremental TUI path above.
|
|
1029
1104
|
private static readonly GRID_HYDRATE_BYTES = 2_000_000;
|
|
1030
|
-
private ensureGrid(): ScreenGrid | null {
|
|
1105
|
+
private ensureGrid(seed?: string): ScreenGrid | null {
|
|
1031
1106
|
if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
|
|
1032
1107
|
if (!this.grid && screenGridsAvailable()) {
|
|
1033
1108
|
this.grid = createScreenGrid(this.activeCols, this.activeRows);
|
|
@@ -1036,8 +1111,8 @@ export class Room extends EventEmitter {
|
|
|
1036
1111
|
if (this.outputBytes > 0) {
|
|
1037
1112
|
// Cut at a line boundary (same trick as boundSnapshotReplay) so a
|
|
1038
1113
|
// slice landing mid-escape-sequence can't garble the parse head.
|
|
1039
|
-
let tail = this.tailOutput(Room.GRID_HYDRATE_BYTES);
|
|
1040
|
-
if (this.outputBytes > tail.length) {
|
|
1114
|
+
let tail = seed ?? this.tailOutput(Room.GRID_HYDRATE_BYTES);
|
|
1115
|
+
if (seed === undefined && this.outputBytes > tail.length) {
|
|
1041
1116
|
const nl = tail.indexOf("\n");
|
|
1042
1117
|
if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
|
|
1043
1118
|
}
|
|
@@ -1051,6 +1126,20 @@ export class Room extends EventEmitter {
|
|
|
1051
1126
|
/** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
|
|
1052
1127
|
private appendOutput(data: string) {
|
|
1053
1128
|
if (!data) return;
|
|
1129
|
+
if (this.gridPendingSeed) {
|
|
1130
|
+
this.gridPendingSeed.push(data);
|
|
1131
|
+
this.gridPendingSeedBytes += data.length;
|
|
1132
|
+
while (this.gridPendingSeed.length > 1
|
|
1133
|
+
&& this.gridPendingSeedBytes - this.gridPendingSeed[0].length >= MAX_BUFFER_SIZE) {
|
|
1134
|
+
this.gridPendingSeedBytes -= this.gridPendingSeed[0].length;
|
|
1135
|
+
this.gridPendingSeed.shift();
|
|
1136
|
+
}
|
|
1137
|
+
if (this.gridPendingSeed.length === 1 && this.gridPendingSeedBytes > MAX_BUFFER_SIZE) {
|
|
1138
|
+
const tail = this.gridPendingSeed[0].slice(-MAX_BUFFER_SIZE);
|
|
1139
|
+
this.gridPendingSeed[0] = tail;
|
|
1140
|
+
this.gridPendingSeedBytes = tail.length;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1054
1143
|
this.outputChunks.push(data);
|
|
1055
1144
|
this.outputBytes += data.length;
|
|
1056
1145
|
while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
|
|
@@ -766,6 +766,25 @@ describe("Room", () => {
|
|
|
766
766
|
expect(snapshot.mouseReporting).toBe(true);
|
|
767
767
|
});
|
|
768
768
|
|
|
769
|
+
it("tracks TUI rows before they age out of the grid hydration tail", async () => {
|
|
770
|
+
const { room, pty } = await setup("early-grid");
|
|
771
|
+
pty().emit("\x1b[?1002h\x1b[?1006h");
|
|
772
|
+
pty().emit("\x1b[2J\x1b[1;1HROW1-EARLY-GRID-MARKER");
|
|
773
|
+
// Repaint only row 20 for more than the lazy grid's 2MB hydration
|
|
774
|
+
// window. A grid first created at attach would have no row 1 to send.
|
|
775
|
+
const updateBatch = ("\x1b[20;1H\x1b[K" + "x".repeat(60)).repeat(64);
|
|
776
|
+
for (let i = 0; i < 500; i++) pty().emit(updateBatch);
|
|
777
|
+
|
|
778
|
+
const socket = new FakeSocket();
|
|
779
|
+
room.attachClient(
|
|
780
|
+
{ id: "late", name: "Late", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
781
|
+
socket
|
|
782
|
+
);
|
|
783
|
+
const snapshot = await waitForSnapshot(socket);
|
|
784
|
+
expect(String(snapshot.data)).toContain("ROW1-EARLY-GRID-MARKER");
|
|
785
|
+
expect(room.getPreviewSource(2_000_000).output).not.toContain("ROW1-EARLY-GRID-MARKER");
|
|
786
|
+
});
|
|
787
|
+
|
|
769
788
|
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
770
789
|
const { room, pty } = await setup("capped");
|
|
771
790
|
const big = "\x1b[20;1H" + "y".repeat(4096);
|
|
@@ -780,6 +799,74 @@ describe("Room", () => {
|
|
|
780
799
|
expect(snapshot.capped).toBe(true);
|
|
781
800
|
});
|
|
782
801
|
|
|
802
|
+
// THE ordering bug behind 'text doesn't update until I scroll' surviving
|
|
803
|
+
// the serialized-attach fix: serialization is ASYNC, the client is already
|
|
804
|
+
// in the broadcast list, so live output landed before the snapshot — and
|
|
805
|
+
// the snapshot's in-band RIS then erased it, while the serialized grid
|
|
806
|
+
// didn't contain those bytes either (they queue behind the serialize
|
|
807
|
+
// marker). Output during the window must be withheld and delivered as
|
|
808
|
+
// catch-up inside the snapshot payload itself.
|
|
809
|
+
it("output racing the serialize is withheld and appended to the snapshot", async () => {
|
|
810
|
+
const { room, pty } = await setup("race");
|
|
811
|
+
pty().emit("\x1b[2J\x1b[1;1Hpre-attach content\r\n");
|
|
812
|
+
const socket = new FakeSocket();
|
|
813
|
+
room.attachClient(
|
|
814
|
+
{ id: "racer", name: "Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
815
|
+
socket
|
|
816
|
+
);
|
|
817
|
+
// Arrives while the snapshot is still serializing (same tick).
|
|
818
|
+
pty().emit("LATE-MARKER-XYZ\r\n");
|
|
819
|
+
const snapshot = await waitForSnapshot(socket);
|
|
820
|
+
const messages = readMessages(socket);
|
|
821
|
+
const snapIdx = messages.findIndex((m) => m.type === "snapshot");
|
|
822
|
+
const outputBefore = messages.slice(0, snapIdx).filter((m) => m.type === "output");
|
|
823
|
+
expect(outputBefore).toHaveLength(0);
|
|
824
|
+
expect(String(snapshot.data)).toContain("LATE-MARKER-XYZ");
|
|
825
|
+
// After the snapshot the stream flows live again, no duplication.
|
|
826
|
+
pty().emit("AFTER-MARKER\r\n");
|
|
827
|
+
const after = readMessages(socket).filter((m) => m.type === "output");
|
|
828
|
+
expect(after.some((m) => String(m.data).includes("AFTER-MARKER"))).toBe(true);
|
|
829
|
+
expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it("retains catch-up larger than the incremental preview limit", async () => {
|
|
833
|
+
const { room, pty } = await setup("large-race");
|
|
834
|
+
pty().emit("\x1b[?1002h\x1b[2J\x1b[1;1Hbefore large catch-up");
|
|
835
|
+
const socket = new FakeSocket();
|
|
836
|
+
room.attachClient(
|
|
837
|
+
{ id: "large-racer", name: "Large Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
838
|
+
socket
|
|
839
|
+
);
|
|
840
|
+
// Larger than getOutputSince's 1MB default: cursor-based catch-up used
|
|
841
|
+
// to reset here and silently drop every withheld byte.
|
|
842
|
+
const late = "x".repeat(1_100_000) + "LARGE-CATCHUP-MARKER";
|
|
843
|
+
pty().emit(late);
|
|
844
|
+
const snapshot = await waitForSnapshot(socket);
|
|
845
|
+
expect(String(snapshot.data)).toContain("LARGE-CATCHUP-MARKER");
|
|
846
|
+
const live = readMessages(socket).filter((m) => m.type === "output");
|
|
847
|
+
expect(live.some((m) => String(m.data).includes("LARGE-CATCHUP-MARKER"))).toBe(false);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("clears tracked private modes when the app sends RIS", async () => {
|
|
851
|
+
const { room, pty } = await setup("ris-modes");
|
|
852
|
+
pty().emit("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?25l\x1b[>1u");
|
|
853
|
+
// Split ESC c across chunks to cover the control-sequence carry path.
|
|
854
|
+
pty().emit("\x1b");
|
|
855
|
+
pty().emit("c\x1b[2J\x1b[1;1HAFTER-RIS");
|
|
856
|
+
const socket = new FakeSocket();
|
|
857
|
+
room.attachClient(
|
|
858
|
+
{ id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
859
|
+
socket
|
|
860
|
+
);
|
|
861
|
+
const snapshot = await waitForSnapshot(socket);
|
|
862
|
+
expect(String(snapshot.data)).toContain("AFTER-RIS");
|
|
863
|
+
expect(snapshot.alternateScreen).toBe(false);
|
|
864
|
+
expect(snapshot.mouseReporting).toBe(false);
|
|
865
|
+
expect(snapshot.mouseSgr).toBe(false);
|
|
866
|
+
expect(snapshot.cursorHidden).toBe(false);
|
|
867
|
+
expect(snapshot.keyboardEnhanced).toBe(false);
|
|
868
|
+
});
|
|
869
|
+
|
|
783
870
|
it("replay=0 means no snapshot at all — live stream only", async () => {
|
|
784
871
|
const { room, pty } = await setup("claim");
|
|
785
872
|
pty().emit("some prior output\r\n");
|