Spaces:
Running
Running
Agent Manager commited on
Commit ·
e78e6fd
1
Parent(s): 290f6a9
Fix terminal zoom geometry and repaint ordering
Browse files- server/resize.test.mjs +45 -1
- server/src/runner.js +22 -9
- web/src/components/TerminalPane.tsx +13 -31
- web/src/styles.css +2 -2
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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(() =>
|
| 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 |
-
|
| 439 |
-
|
| 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 |
-
|
| 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 |
-
//
|
| 533 |
-
//
|
|
|
|
| 534 |
useEffect(() => {
|
| 535 |
const t = termRef.current;
|
| 536 |
if (!t) return;
|
| 537 |
t.options.fontSize = Math.round((13 * zoom) / 100);
|
| 538 |
-
|
| 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;
|
| 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 {
|
| 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 |
|