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
@@ -98,6 +98,14 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// <summary>Completes once the renderer has attached its socket.</summary>
public Task RendererAttached => rendererAttached.Task;
/// <summary>Raised when the page asks for a different font size.</summary>
/// <remarks>
/// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models
/// has to marshal. <see cref="TerminalWorkspace"/> forwards it as it arrives and leaves that to the
/// shell, which is where the thread affinity is known.
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
public void Register(uint sessionId, TerminalSessionPump pump)
{
@@ -385,6 +393,19 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
// Answered before the session lookup, because it is the one frame that is not about a session. The
// page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
// ended is still a request to make the text bigger.
if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
{
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return;
}
TerminalSessionPump? pump;
lock (pumpGate)
{
@@ -76,6 +76,25 @@ public enum TerminalServerOpcode : byte
/// </para>
/// </remarks>
Paste = 6,
/// <summary>
/// What size every pane should draw at. Payload is one byte, the size in CSS pixels.
/// </summary>
/// <remarks>
/// <para>
/// Not per session, although the frame carries a session id as every frame does: a font size is a
/// preference about reading rather than a property of one remote, and somebody who makes the text
/// bigger means all of it. The id is sent as zero and ignored.
/// </para>
/// <para>
/// <b>Changing it reflows the remote, and that is the point.</b> The page refits after applying, which
/// changes the column and row count and sends <see cref="TerminalClientOpcode.Resize"/> back — so a
/// larger font is a terminal with fewer columns, and the far end is told about it. That is why this is
/// a font size rather than a zoom: zoom scales pixels and leaves the remote wrapping to a width that
/// is no longer on screen, which is what made the phone's pinch-zoom worse than useless.
/// </para>
/// </remarks>
FontSize = 7,
}
/// <summary>Frames the renderer sends to the host.</summary>
@@ -94,6 +113,24 @@ public enum TerminalClientOpcode : byte
/// <summary>The terminal was resized. Payload is four big-endian <see cref="ushort"/> values.</summary>
Resize = 3,
/// <summary>
/// The page asking for a different font size. Payload is one signed byte: a step, or zero to reset.
/// </summary>
/// <remarks>
/// <para>
/// A request rather than a statement, and the direction matters. The page owns the keyboard whenever a
/// terminal has focus — the host's window sees no key events at all then — so Ctrl+plus and Ctrl+minus
/// can only be <em>heard</em> there. But the size has to be remembered across launches and shown in a
/// control the page knows nothing about, so it is <em>decided</em> here. The page asks, the host
/// clamps, stores and answers with <see cref="TerminalServerOpcode.FontSize"/>.
/// </para>
/// <para>
/// Which also means one path, not two: 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.
/// </para>
/// </remarks>
FontSizeStep = 4,
}
/// <summary>
@@ -230,6 +267,31 @@ public static class TerminalFrame
return payload;
}
/// <summary>Reads a <see cref="TerminalClientOpcode.FontSizeStep"/> payload.</summary>
/// <remarks>
/// Signed, because the one byte carries a direction as well as a magnitude — and zero means reset,
/// which is why this cannot be a flag pair.
/// </remarks>
public static bool TryReadFontSizeStep(ReadOnlySpan<byte> payload, out int step)
{
step = 0;
if (payload.Length != 1)
{
return false;
}
step = (sbyte)payload[0];
return true;
}
/// <summary>Writes a <see cref="TerminalClientOpcode.FontSizeStep"/> payload.</summary>
public static byte[] CreateFontSizeStepPayload(int step) => [(byte)(sbyte)Math.Clamp(step, -128, 127)];
/// <summary>Writes a <see cref="TerminalServerOpcode.FontSize"/> payload.</summary>
public static byte[] CreateFontSizePayload(int pixels) => [(byte)Math.Clamp(pixels, 1, 255)];
/// <summary>Writes an <see cref="TerminalClientOpcode.Acknowledge"/> payload.</summary>
public static byte[] CreateAcknowledgementPayload(uint rendered)
{
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
public uint SessionId { get; } = sessionId;
}
/// <summary>The renderer asking for a different font size.</summary>
/// <param name="step">
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
/// size because the page does not hold the current one — the host does, and clamping a step is what stops
/// two chords in flight from disagreeing about where they started.
/// </param>
public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
{
/// <summary>The requested move, or zero for "back to the default".</summary>
public int Step { get; } = step;
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
@@ -96,6 +108,11 @@ public sealed class TerminalWorkspace : IAsyncDisposable
this.options = options ?? new TerminalWorkspaceOptions();
dataPlane = new TerminalDataPlane(assets);
// Forwarded rather than re-raised with the workspace as the sender, so a handler can tell where it
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
// what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
}
/// <summary>
@@ -186,9 +203,42 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
/// <summary>Raised when the renderer's own keyboard asks for a different font size.</summary>
/// <remarks>
/// The chords can only be heard by the page — once a terminal has focus the host's window sees no key
/// events at all — so this is how Ctrl+plus reaches the thing that owns the setting. Raised on the
/// socket's receive loop; marshal before touching a view model.
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
/// <summary>
/// Tells every pane what size to draw at.
/// </summary>
/// <remarks>
/// <para>
/// Sent to the page rather than applied per session, and unconditionally rather than only when a
/// session is live: the page keeps the size for panes opened later, so this is also how the first
/// terminal of a launch comes up at the size the user last chose.
/// </para>
/// <para>
/// Every live pane refits as a result and reports its new geometry, so the remotes are told they have
/// fewer columns. That round trip is the feature rather than a side effect — see
/// <see cref="TerminalServerOpcode.FontSize"/>.
/// </para>
/// </remarks>
/// <param name="pixels">The size in CSS pixels. Clamped by the caller; sent as one byte.</param>
/// <param name="cancellationToken">Cancellation.</param>
public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) =>
dataPlane.SendAsync(
TerminalFrame.Create(
(byte)TerminalServerOpcode.FontSize,
sessionId: 0,
TerminalFrame.CreateFontSizePayload(pixels)),
cancellationToken);
/// <summary>
/// Waits until the renderer page has attached its socket.
/// </summary>