Agent Manager commited on
Commit
e78e6fd
·
1 Parent(s): 290f6a9

Fix terminal zoom geometry and repaint ordering

Browse files
server/resize.test.mjs CHANGED
@@ -52,7 +52,7 @@ function view(id, cols, rows, mirror = false) {
52
  ? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
53
  : null;
54
  const v = {
55
- bytes: '', frames: [], term,
56
  type: (data) => ws.send(JSON.stringify({ t: 'i', d: data })),
57
  resize: (nextCols, nextRows) => ws.send(JSON.stringify({ t: 'r', cols: nextCols, rows: nextRows })),
58
  claim: () => ws.send(JSON.stringify({ t: 'claim' })),
@@ -75,6 +75,7 @@ function view(id, cols, rows, mirror = false) {
75
  let frame;
76
  try { frame = JSON.parse(text.slice(CTRL.length)); } catch { return; }
77
  v.frames.push(frame);
 
78
  if (term && (frame.t === 'grid' || frame.t === 'restore')) {
79
  const applyGrid = () => {
80
  if (frame.reset) { term.reset(); term.clear(); }
@@ -90,6 +91,7 @@ function view(id, cols, rows, mirror = false) {
90
  }
91
  } else {
92
  v.bytes += text;
 
93
  if (term) term.write(text);
94
  }
95
  });
@@ -144,6 +146,33 @@ try {
144
  await stop(id);
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  // A drag should issue one final resize, not one SIGWINCH per animation frame.
148
  {
149
  const id = await session('resize storm');
@@ -161,6 +190,21 @@ try {
161
  await stop(id);
162
  }
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  // Reattachment is serialized from Ghostty state, not from a 256 KiB suffix
165
  // of raw PTY traffic. Both the oldest and newest retained rows must return.
166
  {
 
52
  ? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
53
  : null;
54
  const v = {
55
+ bytes: '', frames: [], events: [], term,
56
  type: (data) => ws.send(JSON.stringify({ t: 'i', d: data })),
57
  resize: (nextCols, nextRows) => ws.send(JSON.stringify({ t: 'r', cols: nextCols, rows: nextRows })),
58
  claim: () => ws.send(JSON.stringify({ t: 'claim' })),
 
75
  let frame;
76
  try { frame = JSON.parse(text.slice(CTRL.length)); } catch { return; }
77
  v.frames.push(frame);
78
+ v.events.push({ type: 'frame', frame });
79
  if (term && (frame.t === 'grid' || frame.t === 'restore')) {
80
  const applyGrid = () => {
81
  if (frame.reset) { term.reset(); term.clear(); }
 
91
  }
92
  } else {
93
  v.bytes += text;
94
+ v.events.push({ type: 'data', text });
95
  if (term) term.write(text);
96
  }
97
  });
 
146
  await stop(id);
147
  }
148
 
149
+ // The geometry frame must precede output triggered by SIGWINCH. Otherwise a
150
+ // TUI's repaint is interpreted at the old size and only reflowed afterward,
151
+ // which can leave duplicate or displaced rows in a browser emulator.
152
+ {
153
+ const id = await session('ordered repaint');
154
+ const v = await view(id, 100, 30, true);
155
+ await sleep(500);
156
+ v.type(`trap 'printf "\\033[2J\\033[HRESIZE-AT-%sx%s\\n" "$COLUMNS" "$LINES"' WINCH\r`);
157
+ await sleep(400);
158
+ v.events.length = 0;
159
+ v.resize(73, 21);
160
+ const repainted = await waitFor(() => v.events.some((event) => event.type === 'data'
161
+ && event.text.includes('RESIZE-AT-73x21')));
162
+ const gridAt = v.events.findIndex((event) => event.type === 'frame'
163
+ && event.frame.t === 'grid' && event.frame.cols === 73 && event.frame.rows === 21);
164
+ const repaintAt = v.events.findIndex((event) => event.type === 'data'
165
+ && event.text.includes('RESIZE-AT-73x21'));
166
+ check('SIGWINCH repaint arrives after the confirmed grid', repainted && gridAt >= 0 && gridAt < repaintAt,
167
+ `grid event ${gridAt}, repaint event ${repaintAt}`);
168
+ if (Headless) {
169
+ const screen = await v.screenText();
170
+ check('ordered repaint is rendered once', screen.split('RESIZE-AT-73x21').length - 1 === 1);
171
+ }
172
+ v.close();
173
+ await stop(id);
174
+ }
175
+
176
  // A drag should issue one final resize, not one SIGWINCH per animation frame.
177
  {
178
  const id = await session('resize storm');
 
190
  await stop(id);
191
  }
192
 
193
+ // Low zoom on a large/high-DPI panel can legitimately exceed the former
194
+ // 400x200 cap. The safety bound must not create dead space in that range.
195
+ {
196
+ const id = await session('large panel');
197
+ const v = await view(id, 120, 40);
198
+ await sleep(300);
199
+ v.resize(640, 300);
200
+ const expanded = await waitFor(() => v.lastFrame('grid')?.cols === 640
201
+ && v.lastFrame('grid')?.rows === 300);
202
+ check('large low-zoom panel is not clipped to the legacy grid cap', expanded,
203
+ JSON.stringify(v.lastFrame('grid')));
204
+ v.close();
205
+ await stop(id);
206
+ }
207
+
208
  // Reattachment is serialized from Ghostty state, not from a 256 KiB suffix
209
  // of raw PTY traffic. Both the oldest and newest retained rows must return.
210
  {
server/src/runner.js CHANGED
@@ -69,6 +69,12 @@ const SCROLLBACK_BYTES = Number.isFinite(configuredScrollback) && configuredScro
69
  // fires per animation frame while a window is dragged; coalescing that burst
70
  // avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH.
71
  const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
 
 
 
 
 
 
72
 
73
  const hosts = new Map(); // session id -> host
74
 
@@ -151,6 +157,15 @@ function effectiveGrid(host) {
151
  return host.controller?.want || { cols: host.cols, rows: host.rows };
152
  }
153
 
 
 
 
 
 
 
 
 
 
154
  function notifyGrid(host, reset = false) {
155
  for (const sub of host.subs) {
156
  sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset);
@@ -172,8 +187,12 @@ function applyGrid(host) {
172
  host.cols = cols;
173
  host.rows = rows;
174
  try { host.vt.resize(cols, rows); } catch {}
175
- try { host.pty.resize(cols, rows); } catch {}
 
 
 
176
  notifyGrid(host, false);
 
177
  return true;
178
  }
179
 
@@ -740,10 +759,7 @@ export function attach(session, cols, rows) {
740
  onData: () => {},
741
  onExit: () => {},
742
  onGrid: () => {},
743
- want: {
744
- cols: Math.max(20, Math.min(400, Math.round(Number.isFinite(cols) ? cols : host.cols))),
745
- rows: Math.max(5, Math.min(200, Math.round(Number.isFinite(rows) ? rows : host.rows))),
746
- },
747
  };
748
  host.subs.add(sub);
749
  if (!host.controller) host.controller = sub;
@@ -765,10 +781,7 @@ export function attach(session, cols, rows) {
765
  // controller's request changes the PTY.
766
  resize: (c, r) => {
767
  if (!Number.isFinite(c) || !Number.isFinite(r)) return;
768
- const want = {
769
- cols: Math.max(20, Math.min(400, Math.round(c))),
770
- rows: Math.max(5, Math.min(200, Math.round(r))),
771
- };
772
  const had = sub.want;
773
  sub.want = want;
774
  if (had && had.cols === want.cols && had.rows === want.rows) return;
 
69
  // fires per animation frame while a window is dragged; coalescing that burst
70
  // avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH.
71
  const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
72
+ // Bound untrusted WebSocket geometry without imposing the old 400x200 ceiling,
73
+ // which left visible dead space on high-DPI displays at low zoom levels.
74
+ const MIN_COLS = 20;
75
+ const MIN_ROWS = 5;
76
+ const MAX_COLS = 1000;
77
+ const MAX_ROWS = 500;
78
 
79
  const hosts = new Map(); // session id -> host
80
 
 
157
  return host.controller?.want || { cols: host.cols, rows: host.rows };
158
  }
159
 
160
+ function preferredGrid(cols, rows, fallback) {
161
+ const c = Number.isFinite(cols) ? Math.round(cols) : fallback.cols;
162
+ const r = Number.isFinite(rows) ? Math.round(rows) : fallback.rows;
163
+ return {
164
+ cols: Math.max(MIN_COLS, Math.min(MAX_COLS, c)),
165
+ rows: Math.max(MIN_ROWS, Math.min(MAX_ROWS, r)),
166
+ };
167
+ }
168
+
169
  function notifyGrid(host, reset = false) {
170
  for (const sub of host.subs) {
171
  sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset);
 
187
  host.cols = cols;
188
  host.rows = rows;
189
  try { host.vt.resize(cols, rows); } catch {}
190
+ // Put the geometry frame on every viewer's ordered WebSocket stream before
191
+ // SIGWINCH can make the foreground application repaint at the new size.
192
+ // Otherwise those repaint bytes may be interpreted using the old grid and
193
+ // leave duplicated or displaced rows in the browser emulator.
194
  notifyGrid(host, false);
195
+ try { host.pty.resize(cols, rows); } catch {}
196
  return true;
197
  }
198
 
 
759
  onData: () => {},
760
  onExit: () => {},
761
  onGrid: () => {},
762
+ want: preferredGrid(cols, rows, host),
 
 
 
763
  };
764
  host.subs.add(sub);
765
  if (!host.controller) host.controller = sub;
 
781
  // controller's request changes the PTY.
782
  resize: (c, r) => {
783
  if (!Number.isFinite(c) || !Number.isFinite(r)) return;
784
+ const want = preferredGrid(c, r, host);
 
 
 
785
  const had = sub.want;
786
  sub.want = want;
787
  if (had && had.cols === want.cols && had.rows === want.rows) return;
web/src/components/TerminalPane.tsx CHANGED
@@ -145,7 +145,7 @@ export default function TerminalPane({
145
  }) {
146
  const hostRef = useRef<HTMLDivElement>(null);
147
  const termRef = useRef<Terminal | null>(null);
148
- const localLayoutRef = useRef<() => void>(() => {});
149
  const reconnectRef = useRef<() => void>(() => {});
150
  const controllerRef = useRef(false);
151
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
@@ -199,7 +199,9 @@ export default function TerminalPane({
199
  useEffect(() => {
200
  const term = new Terminal({
201
  fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
202
- fontSize: 13,
 
 
203
  cursorBlink: true,
204
  scrollback: 20000,
205
  theme: THEMES[theme],
@@ -299,25 +301,8 @@ export default function TerminalPane({
299
  // reflow, and it gives the initial size we open the socket with. Every later
300
  // size change goes through resync() as a REQUEST — see there.
301
  try { fit.fit(); } catch { /* layout not ready yet */ }
302
- let layoutFrame = 0;
303
- const syncLocalLayout = () => {
304
- cancelAnimationFrame(layoutFrame);
305
- layoutFrame = requestAnimationFrame(() => {
306
- const box = hostRef.current;
307
- const root = box?.querySelector<HTMLElement>('.xterm');
308
- const screen = box?.querySelector<HTMLElement>('.xterm-screen');
309
- if (!box || !root || !screen) return;
310
- const style = getComputedStyle(box);
311
- const innerWidth = box.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
312
- const innerHeight = box.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
313
- root.style.width = `${Math.max(innerWidth, Math.ceil(screen.getBoundingClientRect().width))}px`;
314
- root.style.height = `${Math.max(innerHeight, Math.ceil(screen.getBoundingClientRect().height))}px`;
315
- });
316
- };
317
- localLayoutRef.current = syncLocalLayout;
318
- syncLocalLayout();
319
  // Re-measure once the webfont is ready (glyph width changes vs the fallback).
320
- document.fonts?.ready.then(() => { resync(); syncLocalLayout(); });
321
 
322
  let closedByUs = false;
323
  let retry: ReturnType<typeof setTimeout> | null = null;
@@ -386,7 +371,6 @@ export default function TerminalPane({
386
  if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
387
  term.resize(m.cols, m.rows);
388
  }
389
- syncLocalLayout();
390
  } catch { /* ignore */ }
391
  };
392
  // Writes are asynchronous. A queued empty write is a barrier so
@@ -435,11 +419,8 @@ export default function TerminalPane({
435
  if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
436
  try {
437
  const d = fit.proposeDimensions();
438
- // Zoom is local presentation, not PTY geometry. Normalize the measured
439
- // cells back to the 13px canonical font before sending a preference.
440
- const scale = (Number(term.options.fontSize) || 13) / 13;
441
- const cols = d ? Math.max(1, Math.floor(d.cols * scale)) : 0;
442
- const rows = d ? Math.max(1, Math.floor(d.rows * scale)) : 0;
443
  if (cols > 0 && rows > 0) send({ t: 'r', cols, rows });
444
  } catch { /* layout not ready */ }
445
  };
@@ -449,6 +430,7 @@ export default function TerminalPane({
449
  if (resyncTimer) clearTimeout(resyncTimer);
450
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
451
  };
 
452
  const onReturn = () => resync();
453
  const onVisible = () => { if (!document.hidden) onReturn(); };
454
  // Typing means the user sees enough to interact — drop the boot cover.
@@ -504,7 +486,6 @@ export default function TerminalPane({
504
  if (bootTimer) clearTimeout(bootTimer);
505
  if (bootCheck) clearTimeout(bootCheck);
506
  if (resyncTimer) clearTimeout(resyncTimer);
507
- cancelAnimationFrame(layoutFrame);
508
  ro.disconnect();
509
  host.removeEventListener('pointerdown', onPointerDown, true);
510
  host.removeEventListener('paste', onPaste, true);
@@ -520,7 +501,7 @@ export default function TerminalPane({
520
  try { ws?.close(); } catch { /* ignore */ }
521
  term.dispose();
522
  termRef.current = null;
523
- localLayoutRef.current = () => {};
524
  };
525
  }, [session.id]);
526
 
@@ -529,13 +510,14 @@ export default function TerminalPane({
529
  if (termRef.current) termRef.current.options.theme = THEMES[theme];
530
  }, [theme]);
531
 
532
- // Zoom changes only this viewer. The canonical terminal rows/columns stay
533
- // fixed, and the host becomes a two-dimensional viewport when cells grow.
 
534
  useEffect(() => {
535
  const t = termRef.current;
536
  if (!t) return;
537
  t.options.fontSize = Math.round((13 * zoom) / 100);
538
- localLayoutRef.current();
539
  }, [zoom]);
540
 
541
  // Move keyboard focus into the terminal whenever this pane becomes the active
 
145
  }) {
146
  const hostRef = useRef<HTMLDivElement>(null);
147
  const termRef = useRef<Terminal | null>(null);
148
+ const resyncRef = useRef<() => void>(() => {});
149
  const reconnectRef = useRef<() => void>(() => {});
150
  const controllerRef = useRef(false);
151
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
 
199
  useEffect(() => {
200
  const term = new Terminal({
201
  fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
202
+ // Start at the requested zoom so attachment does not briefly create a
203
+ // 100% grid and then force an avoidable reflow as the session boots.
204
+ fontSize: Math.round((13 * zoom) / 100),
205
  cursorBlink: true,
206
  scrollback: 20000,
207
  theme: THEMES[theme],
 
301
  // reflow, and it gives the initial size we open the socket with. Every later
302
  // size change goes through resync() as a REQUEST — see there.
303
  try { fit.fit(); } catch { /* layout not ready yet */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  // Re-measure once the webfont is ready (glyph width changes vs the fallback).
305
+ document.fonts?.ready.then(() => resync());
306
 
307
  let closedByUs = false;
308
  let retry: ReturnType<typeof setTimeout> | null = null;
 
371
  if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
372
  term.resize(m.cols, m.rows);
373
  }
 
374
  } catch { /* ignore */ }
375
  };
376
  // Writes are asynchronous. A queued empty write is a barrier so
 
419
  if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
420
  try {
421
  const d = fit.proposeDimensions();
422
+ const cols = d ? Math.max(1, d.cols) : 0;
423
+ const rows = d ? Math.max(1, d.rows) : 0;
 
 
 
424
  if (cols > 0 && rows > 0) send({ t: 'r', cols, rows });
425
  } catch { /* layout not ready */ }
426
  };
 
430
  if (resyncTimer) clearTimeout(resyncTimer);
431
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
432
  };
433
+ resyncRef.current = resync;
434
  const onReturn = () => resync();
435
  const onVisible = () => { if (!document.hidden) onReturn(); };
436
  // Typing means the user sees enough to interact — drop the boot cover.
 
486
  if (bootTimer) clearTimeout(bootTimer);
487
  if (bootCheck) clearTimeout(bootCheck);
488
  if (resyncTimer) clearTimeout(resyncTimer);
 
489
  ro.disconnect();
490
  host.removeEventListener('pointerdown', onPointerDown, true);
491
  host.removeEventListener('paste', onPaste, true);
 
501
  try { ws?.close(); } catch { /* ignore */ }
502
  term.dispose();
503
  termRef.current = null;
504
+ resyncRef.current = () => {};
505
  };
506
  }, [session.id]);
507
 
 
510
  if (termRef.current) termRef.current.options.theme = THEMES[theme];
511
  }, [theme]);
512
 
513
+ // Font size and PTY geometry move together. The controller asks the server
514
+ // for the grid that actually fits this pane; the confirmed grid then resizes
515
+ // every viewer in order with the PTY output stream.
516
  useEffect(() => {
517
  const t = termRef.current;
518
  if (!t) return;
519
  t.options.fontSize = Math.round((13 * zoom) / 100);
520
+ resyncRef.current();
521
  }, [zoom]);
522
 
523
  // Move keyboard focus into the terminal whenever this pane becomes the active
web/src/styles.css CHANGED
@@ -558,7 +558,7 @@ body {
558
  .slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
559
  .slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
560
 
561
- .term-host { flex: 1; min-height: 0; padding: 10px 8px 8px 12px; background: var(--term-bg); overflow: auto; overscroll-behavior: contain; }
562
  /* The grid is measured against this filler, so it must stay unpadded: FitAddon
563
  reads the PARENT's computed height and does NOT subtract its padding, so a
564
  padded parent made the grid count a row that doesn't fit and the bottom line
@@ -588,7 +588,7 @@ body {
588
  .term-paste .tp-input { width: 100%; box-sizing: border-box; resize: none; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
589
  background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
590
  .term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
591
- .term-host .xterm { min-width: 100%; min-height: 100%; }
592
  .xterm .xterm-viewport { scrollbar-width: none; }
593
  .xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
594
 
 
558
  .slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
559
  .slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
560
 
561
+ .term-host { flex: 1; min-height: 0; background: var(--term-bg); overflow: hidden; }
562
  /* The grid is measured against this filler, so it must stay unpadded: FitAddon
563
  reads the PARENT's computed height and does NOT subtract its padding, so a
564
  padded parent made the grid count a row that doesn't fit and the bottom line
 
588
  .term-paste .tp-input { width: 100%; box-sizing: border-box; resize: none; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
589
  background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
590
  .term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
591
+ .term-host .xterm { width: 100%; height: 100%; padding: 10px 8px 8px 12px; }
592
  .xterm .xterm-viewport { scrollbar-width: none; }
593
  .xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
594