hop2 0.11.78 → 0.11.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/apps/server/src/rooms.ts +20 -3
- package/hay/apps/server/test/rooms.test.ts +12 -2
- package/hay/apps/web/src/App.tsx +4 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +18 -0
- package/hay/apps/web/src/styles.css +3 -0
- package/hop +5 -3
- package/package.json +1 -1
- package/tests/terminal-api.integration.test.cjs +27 -0
|
@@ -1226,7 +1226,7 @@ export class Room extends EventEmitter {
|
|
|
1226
1226
|
* With a client attached, the real terminal answers and we stay silent.
|
|
1227
1227
|
*/
|
|
1228
1228
|
private answerTerminalQueriesIfHeadless(data: string) {
|
|
1229
|
-
if (this.
|
|
1229
|
+
if (this.ended) return;
|
|
1230
1230
|
if (data.indexOf("\x1b") === -1) return;
|
|
1231
1231
|
let reply = "";
|
|
1232
1232
|
// OSC 10/11: foreground / background color queries — answered with the
|
|
@@ -1234,12 +1234,29 @@ export class Room extends EventEmitter {
|
|
|
1234
1234
|
// background (Claude Code picks light vs dark this way) matches what the
|
|
1235
1235
|
// user is really looking at. Falls back to the dark default before any
|
|
1236
1236
|
// client has ever attached.
|
|
1237
|
+
//
|
|
1238
|
+
// Answered even while browsers are attached: the web terminal swallows
|
|
1239
|
+
// OSC 10/11 without replying, so "a client will answer" was never true
|
|
1240
|
+
// for them, and a Codex started with a tile or a viewer open got no
|
|
1241
|
+
// reply — it fell back to its dark palette and an unshaded input box
|
|
1242
|
+
// (alphagenome, 2026-10-15). Only a local CLI attach sits in a real
|
|
1243
|
+
// terminal that answers for itself.
|
|
1244
|
+
const answersColors = [...this.clients.values()].some((client) => client.source === "local-cli");
|
|
1237
1245
|
const osc = (n: number, hex: string) => {
|
|
1238
1246
|
const p = (i: number) => hex.slice(i, i + 2).repeat(2);
|
|
1239
1247
|
return `\x1b]${n};rgb:${p(0)}/${p(2)}/${p(4)}\x1b\\`;
|
|
1240
1248
|
};
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1249
|
+
if (!answersColors) {
|
|
1250
|
+
if (/\x1b\]10;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(10, this.clientFg);
|
|
1251
|
+
if (/\x1b\]11;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(11, this.clientBg);
|
|
1252
|
+
}
|
|
1253
|
+
// The rest xterm.js answers itself, so only a room nobody watches does.
|
|
1254
|
+
if (this.clients.size > 0) {
|
|
1255
|
+
if (reply) {
|
|
1256
|
+
try { this.pty.write(reply); } catch { /* pty gone */ }
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1243
1260
|
// DA1: identify as a VT220-class terminal (matches xterm.js's own reply).
|
|
1244
1261
|
if (/\x1b\[0?c/.test(data)) reply += "\x1b[?62;22c";
|
|
1245
1262
|
// DSR 5 (status): "OK".
|
|
@@ -647,7 +647,7 @@ describe("Room", () => {
|
|
|
647
647
|
});
|
|
648
648
|
|
|
649
649
|
describe("headless terminal-identity answers", () => {
|
|
650
|
-
it("answers
|
|
650
|
+
it("answers DA/truecolor probes only while no client is attached; colors whenever no real terminal is", () => {
|
|
651
651
|
let ptyInstance: FakePty | null = null;
|
|
652
652
|
const factory: PtyFactory = () => {
|
|
653
653
|
ptyInstance = new FakePty() as unknown as FakePty;
|
|
@@ -663,9 +663,19 @@ describe("Room", () => {
|
|
|
663
663
|
expect(answered).toContain("[?62;22c");
|
|
664
664
|
expect(answered).toContain("P1+r524742");
|
|
665
665
|
|
|
666
|
-
//
|
|
666
|
+
// A browser attached: xterm.js answers DA itself, but swallows OSC
|
|
667
|
+
// 10/11 without replying — the room still answers the color, or a
|
|
668
|
+
// Codex started under a viewer gets no reply and goes dark.
|
|
667
669
|
room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, new FakeSocket());
|
|
668
670
|
ptyInstance!.writes.length = 0;
|
|
671
|
+
ptyInstance!.emit("\x1b]11;?\x07 \x1b[0c");
|
|
672
|
+
const withBrowser = ptyInstance!.writes.join("");
|
|
673
|
+
expect(withBrowser).toContain("]11;rgb:0d0d/1111/1717");
|
|
674
|
+
expect(withBrowser).not.toContain("[?62;22c");
|
|
675
|
+
|
|
676
|
+
// A local CLI attach sits in a real terminal that answers for itself.
|
|
677
|
+
room.attachClient({ id: "c", name: "cli", colorIndex: 1, cols: 80, rows: 24, source: "local-cli" }, new FakeSocket());
|
|
678
|
+
ptyInstance!.writes.length = 0;
|
|
669
679
|
ptyInstance!.emit("\x1b]11;?\x07");
|
|
670
680
|
expect(ptyInstance!.writes.join("")).toBe("");
|
|
671
681
|
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -4351,6 +4351,8 @@ const App = () => {
|
|
|
4351
4351
|
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
4352
4352
|
userName={name}
|
|
4353
4353
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4354
|
+
themeMode={themeMode}
|
|
4355
|
+
onThemeChange={setThemeMode}
|
|
4354
4356
|
/>
|
|
4355
4357
|
{toast && <div className="terminal-toast" role="status" aria-live="polite">{toast}</div>}
|
|
4356
4358
|
</main>
|
|
@@ -5016,6 +5018,8 @@ const App = () => {
|
|
|
5016
5018
|
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
5017
5019
|
userName={name}
|
|
5018
5020
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
5021
|
+
themeMode={themeMode}
|
|
5022
|
+
onThemeChange={setThemeMode}
|
|
5019
5023
|
onOpenSettings={() => {
|
|
5020
5024
|
setSwitcherOpen(false);
|
|
5021
5025
|
setDrawerOpen(true);
|
|
@@ -113,6 +113,10 @@ type Props = {
|
|
|
113
113
|
userName?: string;
|
|
114
114
|
terminalTheme?: object;
|
|
115
115
|
onOpenSettings?: () => void;
|
|
116
|
+
// The page theme, switchable straight from the wall: the settings drawer
|
|
117
|
+
// lives in the full-screen view, and the hub (no session open) has none.
|
|
118
|
+
themeMode?: "system" | "light" | "dark";
|
|
119
|
+
onThemeChange?: (mode: "system" | "light" | "dark") => void;
|
|
116
120
|
onToggleKeyboard?: () => void;
|
|
117
121
|
onFind?: () => void;
|
|
118
122
|
// Open the Views panel. No argument = the whole fleet (header button); an
|
|
@@ -1299,6 +1303,8 @@ export const SessionSwitcher = ({
|
|
|
1299
1303
|
userName,
|
|
1300
1304
|
terminalTheme,
|
|
1301
1305
|
onOpenSettings,
|
|
1306
|
+
themeMode,
|
|
1307
|
+
onThemeChange,
|
|
1302
1308
|
onToggleKeyboard,
|
|
1303
1309
|
onFind,
|
|
1304
1310
|
onOpenViews,
|
|
@@ -3344,6 +3350,18 @@ export const SessionSwitcher = ({
|
|
|
3344
3350
|
: (() => { const n = visibleSessions.reduce((k, s) => k + (s.views?.count || 0), 0); return n > 0 ? <span className="views-entry-count">{n}</span> : null; })()}
|
|
3345
3351
|
</button>
|
|
3346
3352
|
)}
|
|
3353
|
+
{onThemeChange && themeMode && (() => {
|
|
3354
|
+
const next = themeMode === "system" ? "light" : themeMode === "light" ? "dark" : "system";
|
|
3355
|
+
const label = (m: string) => (m === "system" ? "Auto" : m === "light" ? "Light" : "Dark");
|
|
3356
|
+
return (
|
|
3357
|
+
<button type="button" className="switcher-action theme-entry" onClick={() => onThemeChange(next)}
|
|
3358
|
+
aria-label={`Theme: ${label(themeMode)} — switch to ${label(next)}`}
|
|
3359
|
+
title={`Theme: ${label(themeMode)} — click for ${label(next)}`}>
|
|
3360
|
+
<span aria-hidden="true">{themeMode === "light" ? "☀" : themeMode === "dark" ? "☾" : "◐"}</span>
|
|
3361
|
+
{label(themeMode)}
|
|
3362
|
+
</button>
|
|
3363
|
+
);
|
|
3364
|
+
})()}
|
|
3347
3365
|
{onOpenSettings && (
|
|
3348
3366
|
<button type="button" className="switcher-action" aria-label="Settings" title="Settings" onClick={onOpenSettings}>
|
|
3349
3367
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -4889,6 +4889,9 @@ body.pane-dragging { user-select: none; }
|
|
|
4889
4889
|
}
|
|
4890
4890
|
.views-entry-count.fresh { background: var(--accent, #7c5cff); color: #fff; }
|
|
4891
4891
|
.switcher-action.views-entry { width: auto; padding: 0 10px; gap: 6px; font-size: 12.5px; font-weight: 600; }
|
|
4892
|
+
/* The theme button carries a word too ("Light"), so it sizes like Views
|
|
4893
|
+
rather than the 32px icon square. */
|
|
4894
|
+
.switcher-action.theme-entry { width: auto; padding: 0 10px; gap: 6px; font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
|
4892
4895
|
|
|
4893
4896
|
/* The switcher's header action is a fixed 32px square with a centred glyph —
|
|
4894
4897
|
an inline dot would shove the icon off-centre, so it rides the corner. */
|
package/hop
CHANGED
|
@@ -11917,6 +11917,7 @@ happens until someone clicks it.
|
|
|
11917
11917
|
}
|
|
11918
11918
|
res.writeHead(206, {
|
|
11919
11919
|
'Content-Type': viewType,
|
|
11920
|
+
'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(path.basename(target))}`,
|
|
11920
11921
|
'Content-Length': end - start + 1,
|
|
11921
11922
|
'Content-Range': `bytes ${start}-${end}/${viewSize}`,
|
|
11922
11923
|
'Accept-Ranges': 'bytes'
|
|
@@ -11929,9 +11930,10 @@ happens until someone clicks it.
|
|
|
11929
11930
|
// anything else in them.
|
|
11930
11931
|
const downloadName = path.basename(target);
|
|
11931
11932
|
const asciiName = downloadName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
11932
|
-
|
|
11933
|
-
|
|
11934
|
-
|
|
11933
|
+
// Inline still names the file: a type the browser can't show
|
|
11934
|
+
// (pptx, zip, octet-stream) is saved anyway, and without a name it
|
|
11935
|
+
// took the URL's last segment — every such download read "inline".
|
|
11936
|
+
const disposition = { 'Content-Disposition': `${wantDownload ? 'attachment' : 'inline'}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(downloadName)}` };
|
|
11935
11937
|
res.writeHead(200, {
|
|
11936
11938
|
...disposition,
|
|
11937
11939
|
'Content-Type': viewType,
|
package/package.json
CHANGED
|
@@ -870,6 +870,33 @@ test('fork falls past a poisoned record cwd to the durable one', async () => {
|
|
|
870
870
|
});
|
|
871
871
|
|
|
872
872
|
|
|
873
|
+
// A view the browser cannot display (pptx, zip) is saved instead of shown.
|
|
874
|
+
// Opening it must still name the file — without a Content-Disposition name
|
|
875
|
+
// the browser took the URL's last segment and every download read "inline".
|
|
876
|
+
test('a view keeps its file name when opened, downloaded or range-read', async () => {
|
|
877
|
+
const dir = path.join(hopHome, 'views', 'nameprobe');
|
|
878
|
+
await fs.mkdir(dir, { recursive: true });
|
|
879
|
+
await fs.writeFile(path.join(dir, 'deck v2.pptx'), Buffer.alloc(64, 1));
|
|
880
|
+
const head = (reqPath, extra = {}) => new Promise((resolve, reject) => {
|
|
881
|
+
const req = http.request({ hostname: '127.0.0.1', port: state.port, path: reqPath, method: 'GET',
|
|
882
|
+
headers: { Authorization: `Bearer ${state.sessionSecret}`, ...extra } }, (res) => {
|
|
883
|
+
res.resume(); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers }));
|
|
884
|
+
});
|
|
885
|
+
req.on('error', reject); req.end();
|
|
886
|
+
});
|
|
887
|
+
const base = '/view/nameprobe/' + encodeURIComponent('deck v2.pptx');
|
|
888
|
+
const opened = await head(base + '/inline');
|
|
889
|
+
assert.equal(opened.status, 200);
|
|
890
|
+
assert.match(opened.headers['content-disposition'] || '', /^inline;.*filename\*=UTF-8''deck%20v2\.pptx/);
|
|
891
|
+
const saved = await head(base + '/download');
|
|
892
|
+
assert.match(saved.headers['content-disposition'] || '', /^attachment;.*filename="deck v2\.pptx"/);
|
|
893
|
+
const ranged = await head(base + '/inline', { Range: 'bytes=0-9' });
|
|
894
|
+
assert.equal(ranged.status, 206);
|
|
895
|
+
assert.match(ranged.headers['content-disposition'] || '', /deck%20v2\.pptx/);
|
|
896
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
|
|
873
900
|
// "Continue in fresh Claude": the same agent, started over from the
|
|
874
901
|
// condensed trajectory instead of resuming the full history. The new session
|
|
875
902
|
// runs the SOURCE's claude launch (a fake one here, so no real agent starts)
|