fix(terminal): correct mobile sizing — fit after web-font load + open PTY at the visible grid

On a phone the terminal clipped on the right: FitAddon measured the column count before
JetBrains Mono finished loading (narrower fallback metrics → too many columns → the wider real
font then overflowed and the right edge was cut). And the PTY opened at a fixed 80x24, so the
remote shell's first screenful (MOTD/prompt) was generated at 80 columns and wrapped awkwardly
on a ~43-column viewport.

- refit immediately, on the next animation frame, and again on document.fonts.ready (font race)
- refit on the 'ready' frame before sending the size
- pass the browser's cols/rows on the WS URL; the sidecar opens the host PTY / SSH shell at that
  size (clamped 1..1000), so output is born at the visible width

Verified in-browser at 390px: PTY reports 43x23 (matches the box), no horizontal overflow, clean
wrapping, zero console errors; desktop server+host terminals and cross-origin rejection still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 08:08:12 +02:00
parent 05042c3582
commit e3b5aa5902
2 changed files with 38 additions and 10 deletions

View File

@ -66,11 +66,11 @@ function bindInput(ws, io) {
});
}
function startHost(ws) {
function startHost(ws, size) {
let p;
try {
p = pty.spawn(HOST_SHELL, ['-l'], {
name: 'xterm-256color', cwd: HOST_CWD, cols: 80, rows: 24,
name: 'xterm-256color', cwd: HOST_CWD, cols: size.cols, rows: size.rows,
// HOME = the landing dir so the shell opens at ~ (not a bare absolute path) and the prompt
// reads cleanly; the container's `hostname: clusev` makes \h show "clusev", not the hash.
env: { ...process.env, TERM: 'xterm-256color', HOME: HOST_CWD },
@ -87,12 +87,12 @@ function startHost(ws) {
ws.on('close', () => { try { p.kill(); } catch (_) {} });
}
function startSsh(ws, spec) {
function startSsh(ws, spec, size) {
const conn = new Client();
let stream = null;
conn.on('ready', () => {
conn.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, s) => {
conn.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (err, s) => {
if (err) { send(ws, { type: 'error', data: 'PTY: ' + err.message }); conn.end(); return; }
stream = s;
send(ws, { type: 'ready' });
@ -139,7 +139,17 @@ wss.on('connection', async (ws, req) => {
}
let token = '';
try { token = new URL(req.url, 'http://x').searchParams.get('token') || ''; } catch (_) {}
const size = { cols: 80, rows: 24 };
try {
const q = new URL(req.url, 'http://x').searchParams;
token = q.get('token') || '';
// Open the PTY at the browser's reported grid so the first screenful matches the viewport. Clamp
// to sane bounds (a bad/huge value must never reach pty/ssh); fall back to 80×24 when absent.
const c = parseInt(q.get('cols'), 10);
const r = parseInt(q.get('rows'), 10);
if (c >= 1 && c <= 1000) size.cols = c;
if (r >= 1 && r <= 1000) size.rows = r;
} catch (_) {}
if (!token || !SECRET) { send(ws, { type: 'error', data: 'Sitzung ungültig.' }); ws.close(); return; }
let spec;
@ -151,8 +161,8 @@ wss.on('connection', async (ws, req) => {
return;
}
if (spec && spec.kind === 'host') startHost(ws);
else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec);
if (spec && spec.kind === 'host') startHost(ws, size);
else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec, size);
else { send(ws, { type: 'error', data: 'Kein gültiges Ziel.' }); ws.close(); }
});

View File

@ -380,7 +380,11 @@ document.addEventListener('alpine:init', () => {
this.fit = new FitAddon();
this.term.loadAddon(this.fit);
this.term.open(this.$refs.screen);
try { this.fit.fit(); } catch (_) {}
// Fit now, next frame (after layout), and again once the web font finishes loading.
// The last one is critical: FitAddon measures the cell width to pick the column count;
// if it runs while JetBrains Mono is still loading it measures the (narrower) fallback,
// over-counts columns, then the wider real font overflows and the right edge gets clipped.
this._refit();
this.term.onData((d) => { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: 'data', data: d })); });
this._ro = new ResizeObserver(() => { try { this.fit.fit(); this._sendResize(); } catch (_) {} });
@ -390,6 +394,15 @@ document.addEventListener('alpine:init', () => {
if (this._pending) { const t = this._pending; this._pending = null; this._connect(t); }
},
// Re-measure + resize the grid to the container: immediately, after the next layout frame, and
// once web fonts settle (so column math uses the real monospace metrics, not a fallback).
_refit() {
const f = () => { try { this.fit.fit(); this._sendResize(); } catch (_) {} };
f();
requestAnimationFrame(f);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(f).catch(() => {});
},
destroy() {
this._close();
if (this._ro) this._ro.disconnect();
@ -408,7 +421,12 @@ document.addEventListener('alpine:init', () => {
this.status = 'connecting';
if (this.term) this.term.reset();
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}`);
// Hand the current grid size to the sidecar so the PTY (and the remote shell's MOTD/prompt)
// is born at the visible width — otherwise it starts at 80×24 and the first screenful wraps
// awkwardly on a narrow (mobile) viewport until the first resize lands.
const cols = (this.term && this.term.cols) || 80;
const rows = (this.term && this.term.rows) || 24;
const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`);
this.ws = ws;
// Every handler is bound to THIS socket: a superseded connection (the user picked another
// target) still fires onclose/onerror asynchronously, which would otherwise clobber the
@ -418,7 +436,7 @@ document.addEventListener('alpine:init', () => {
let m;
try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.type === 'data') this.term.write(m.data);
else if (m.type === 'ready') { this.status = 'connected'; this._sendResize(); setTimeout(() => this.term.focus(), 0); }
else if (m.type === 'ready') { this.status = 'connected'; this._refit(); setTimeout(() => this.term.focus(), 0); }
else if (m.type === 'error') { this.status = 'error'; this.term.write(`\r\n\x1b[31m${m.data}\x1b[0m\r\n`); }
else if (m.type === 'exit') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Sitzung beendet —\x1b[0m\r\n'); }
};