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.
@@ -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.clients.size > 0 || this.ended) return;
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 (/\x1b\]10;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(10, this.clientFg);
1242
- if (/\x1b\]11;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(11, this.clientBg);
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 color/DA/truecolor probes only while no client is attached", () => {
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
- // Attached: the real terminal answers; the room stays silent.
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
  });
@@ -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
- const disposition = wantDownload
11933
- ? { 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(downloadName)}` }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.78",
3
+ "version": "0.11.80",
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": {
@@ -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)