Let the terminal's text be made bigger, and remember how big
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 24s

Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop
either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales
what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen.
Changing the font size refits the grid and reports the new column count, so the far end is told it
has fewer columns. That round trip is the feature.

The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it
must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the
page asks and the host decides — a signed step over a new client opcode, answered with a size over a
new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size
set by either is the size both remember.

Stored in settings.json beside the cache rather than in it, and that is not laziness about a
migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of
a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so
out loud, because the next person to add a preference is the one who needs to read it.

Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the
connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text
must never be the thing that is off-screen. The desktop gets the three chords every terminal
emulator has, answered by the page while a terminal has focus and by the window when it does not,
plus a row in preferences that shows the current value and names the chords rather than replacing
them. Someone whose terminal is too small to read is not in a position to go looking.

Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every
column of it is still a column the remote is being told exists; above thirty-two a phone in portrait
has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses
that do nothing, which on a terminal reads as the application having stopped responding.

The preferences screen's header comment claimed none of the design's terminal settings could be
saved, and listed the three things that were missing to make one work. All three now exist, so it
says which one is real and why the other five still are not.

Verified with the protocol suite — including that the step byte round-trips signed, since read
unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most
dramatic available version of "larger" — a data-plane test that the chord is heard with no session
registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works,
and a size chosen in one shell is there in a second one over the same profile directory. Layout
suite and both heads build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:15:11 +02:00
co-authored by Claude Opus 5
parent 5cb9ffaf9d
commit c00e5dbc5c
13 changed files with 742 additions and 11 deletions
+87 -4
View File
@@ -23,14 +23,24 @@ const SERVER_SESSION_CLOSED = 3;
const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
const SERVER_FONT_SIZE = 7;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
const CLIENT_FONT_SIZE_STEP = 4;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
/*
The size panes are created at, until the host says otherwise — which it does as soon as it has read
the stored preference, usually before the first session exists. Kept here as well so a pane opened
before that frame arrives is not created at some other size and then jumped.
*/
const DEFAULT_FONT_SIZE = 13;
let fontSize = DEFAULT_FONT_SIZE;
/*
The way out of the terminal, for someone using only a keyboard.
@@ -107,17 +117,78 @@ function releaseFocusToHost() {
}
/**
* Swallows the release-focus shortcut so it never reaches the remote.
* Asks the host to move the font size, or to put it back (step 0).
*
* Returning false stops xterm processing the event, which is what keeps the chord from being encoded
* A request rather than a change made here: the host owns the size, because the host is what remembers
* it between launches and what draws the buttons the phone uses. The answer arrives as a
* SERVER_FONT_SIZE frame, so this route and that one end in the same place.
*/
function requestFontSizeStep(step) {
const payload = new Uint8Array(1);
new DataView(payload.buffer).setInt8(0, step);
// Session zero: the size is not a property of any one terminal.
send(CLIENT_FONT_SIZE_STEP, 0, payload);
}
/**
* Applies a size to every pane, and to panes opened after this.
*
* Refitting is not optional. The cell size has changed, so the column and row counts have too, and a
* pane left unfitted draws a grid the remote is not wrapping to. fit() sends the resize frame that
* tells the far end.
*/
function applyFontSize(size) {
fontSize = size;
for (const [sessionId, session] of sessions) {
session.term.options.fontSize = size;
resize(session, sessionId);
}
}
/**
* Swallows the shortcuts that belong to the terminal application rather than to the remote.
*
* Returning false stops xterm processing the event, which is what keeps a chord from being encoded
* and written to the pty.
*/
function handleKey(event) {
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && event.key === 'F6') {
if (event.type !== 'keydown') {
return true;
}
if (event.ctrlKey && event.shiftKey && event.key === 'F6') {
releaseFocusToHost();
return false;
}
/*
Ctrl with plus, minus and zero — what every terminal emulator and every browser uses for text size,
and it has to be caught here for the reason the release-focus chord does: while a terminal has focus
the host's window receives no key events at all, so nothing on that side could hear it.
Both spellings of plus, because the key that is drawn as + on the keycap reports as '+' when Shift
is read and as '=' when it is not, and which one arrives is not something the person pressing it
should have to know. Same for minus and underscore.
*/
if (event.ctrlKey && !event.altKey) {
if (event.key === '+' || event.key === '=') {
requestFontSizeStep(1);
return false;
}
if (event.key === '-' || event.key === '_') {
requestFontSizeStep(-1);
return false;
}
if (event.key === '0') {
requestFontSizeStep(0);
return false;
}
}
return true;
}
@@ -131,7 +202,7 @@ function createSession(sessionId) {
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
fontSize: 13,
fontSize,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background.
theme: { background: '#10131a', foreground: '#d5d8de' },
@@ -309,6 +380,18 @@ function handleFrame(buffer) {
break;
}
case SERVER_FONT_SIZE: {
if (payload.length < 1) {
break;
}
// Applied even with no sessions open, which is the common case at startup: the host sends the
// stored size as soon as this page attaches, and the first pane is then created at it rather
// than being created small and resized in front of the user.
applyFontSize(payload[0]);
break;
}
case SERVER_SESSION_CLOSED: {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);