Skip to content

Size a session to the device being used - #591

Merged
jiweiyuan merged 12 commits into
mainfrom
feat/size-follows-the-device-in-use
Sep 2, 2026
Merged

Size a session to the device being used#591
jiweiyuan merged 12 commits into
mainfrom
feat/size-follows-the-device-in-use

Conversation

@jiweiyuan

@jiweiyuan jiweiyuan commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

The PTY was sized to the smallest viewport rendering a session (shipped this afternoon in #580/#586). Any second viewer then held every other one down to its width, and the first report came within hours: with another window attached, collapsing a pane's right sidebar resized nothing at all. It does not take a phone — two dev builds on one channel is enough, and every viewer of a session is a hostage to the smallest one.

Measured against a real daemon before the change. One window: ⌘⌥0 moves the PTY 41×63 ↔ 41×97, every time. A second window at 700×600 attached to the same session: pinned at 41×61, four toggles move nothing, the app logging PTY is now 41x61; this pane has room for 41x70. Quit the second window and it works again.

The policy

size = viewport of the most recently used attachment
       among those interactive, rendering, and having declared one

used by: typing · declaring a changed viewport · attaching
not by:  output · device reports · gaining the write token

This is tmux's latest (which tmux ships as its default, resize.c:179, keyed on a stored w->latest promoted by keypress and client resize), with zellij's refinement that only attachments actually rendering count. What made termio's old latest oscillate is not here: both ends used to re-assert their own grid on every token grant, so a misclassified byte became a full-speed resize loop. The daemon is the only thing that resizes now, so a stray use costs one resize.

Everything §1–§3 of the RFC argues — that size must not ride on the write token, and that the host owns the policy — is unchanged and is what makes this safe.

What it costs

A phone can be narrower than the PTY again, which is the case smallest-wins existed to make impossible. So iOS lays its surface out at the shared grid and scales it down to fit, as it did before, and measures its viewport from the terminal host rather than from the surface — otherwise a screen that has been scaled can only ever declare the grid it was scaled to, and the session can never come back to it. The surface's real grid rides beside the viewport on the companion resize (surfaceCols/surfaceRows, optional, absent = fills the screen) because the Mac's bridge has no surface of its own to read it from.

Also in here

  • A drag collapses to one resize, a discrete change goes at once. The old scheduler treated both as the same event: a sidebar toggle waited out the full 50ms window, and a drag re-armed it on every frame. It now tells them apart — a change arriving after a quiet spell has nothing to coalesce with and goes immediately, a stream collapses to one send when it stops. 150ms rather than ghostty's 25ms because a resize here is a barrier, not an ioctl on a PTY one surface owns alone; a cadence-flushed drag was twenty barriers a second, visible as the terminal shaking and as rows left behind at the width they were drawn at.
  • The daemon records the size the kernel kept, not the one it asked for. Pty::resize reads TIOCGWINSZ back and the fault is logged. The child reflows from its own TIOCGWINSZ, so the kernel's copy is the one every viewer's bytes are wrapped for; recording a request nothing verified meant the barrier, the keyframe and E resized could all describe a screen that does not exist.
  • One viewport arithmetic for both clients. TerminalGrid.fitting in TermioShared, replacing a copy per platform of the same floor. Two copies of a formula whose entire job is that the two ends agree is what drifts a column apart.
  • The companion bridge stops declaring a stand-in viewport. It attaches with a zero grid, which the daemon reads as no viewport at all — §5.4 said so and the code did not. Harmless under smallest-wins; not harmless when an attach is a use.

Tests

  • termiod/src/session.rs — the session is the viewport of the attachment most recently used; the answer is one screen's grid and never a blend of two; changing a viewport is a use and a token claim is not; a hidden attachment stops counting and starts again; nobody rendering leaves the size alone; a zero viewport counts for nobody; an observer never sizes anything.
  • TermiodSizePolicyIntegrationTests — two Swift attachments against a real daemon: typing on a screen brings the session to it, a viewer that is merely attached does not pull it back, and resizing a pane with a second viewer attached resizes the session.
  • TerminalViewportGridTests — the shared floor: padding on both sides, short-of-a-cell does not round up, NaN/∞ refused rather than trapping into UInt16.

Verified end to end on a dev build with Claude Code in the pane: the daemon's size and the kernel's TIOCGWINSZ agree, and the pane's viewport never diverged from the PTY across window resizes and sidebar toggles.

Release Notes:

  • The terminal now follows the window you are working in. A session open in a second window, or on your phone, no longer holds the one in front of you at its width.
  • Dragging a window edge reflows the terminal while you drag, instead of when you let go.

@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
termio Ready Ready Preview Sep 2, 2026 10:00am UTC

Request Review

The PTY was sized to the smallest viewport rendering a session, so any
second viewer — another window on the same daemon, a phone with the
session open — held every other one down to its width. With one attached,
collapsing a pane's sidebar resized nothing at all.

The daemon now sizes a session to the viewport of the attachment most
recently used, where a use is typing, declaring a changed viewport, or
attaching, and never a byte the terminal produced or a write-token claim.
That is tmux's `latest`, kept safe by what this replaces: one authority
resizes, so a stray use costs one resize rather than the oscillation two
ends re-asserting their own grids produced.

A phone can be narrower than the PTY again, so iOS lays its surface out at
the shared grid and scales it down, and measures its viewport from the
terminal host rather than from the surface — a screen declaring the grid it
was scaled to could never say it had room for its own width back. The
surface's real grid travels beside the viewport on the companion resize so
the Mac's bridge, which has no surface, can still arm the repaint.

Also here: a moving viewport flushes on a cadence rather than once it
settles, so a drag reflows while it happens; the daemon records the size
the kernel kept rather than the one it asked for; and both clients measure
their viewport with one shared function.
A viewport flushed on a fixed cadence made a drag twenty barriers a
second: the session quiesces, resizes and pushes a fresh keyframe to every
attachment each time, so the terminal shook while it was dragged and an
agent TUI's incremental repaint lost the race often enough to leave rows
drawn at the width they came from.

The two cases a pane produces are different and the scheduler now tells
them apart. A discrete change — collapsing the sidebar, splitting, a font
size — arrives alone after a quiet spell and goes at once, because there
is nothing to coalesce it with and waiting on it is pure lag. A drag is a
stream whose intermediate sizes nobody asked for, so it collapses to one
send when it stops. Two barriers for a drag rather than one per 50ms.

150ms rather than ghostty's 25ms because a resize here is a barrier and
not an ioctl on a PTY one surface owns alone: tmux rate-limits a pane to
one resize per 250ms, zellij collapses a burst to its last, cmux debounces
180ms, and none of them are wrong about it.
Resizing truncated the authoritative screen instead of rewrapping it, so a
line the program had wrapped stayed broken where it was broken: widen the
window and rows start mid-word, and every attachment repaints from that
screen. It is what a dragged window looks like wrong.

The truncating resize is right, but only for the case it was chosen for.
zsh's SIGWINCH redisplay computes how far to move the cursor from the
*old* width, so rewrapping under it duplicates the prompt — the ⌘D report.
That arithmetic is the shell's, and the shell only does it while it holds
the terminal. When a job holds it — an agent TUI, an editor — nobody is
measuring against the old screen, and the screen can be rewrapped the way
every terminal these programs are written for does it.

So the resize now asks the foreground: `foreground.job` is already the
answer, and it is the session's own pgid comparison rather than a name
match. Both halves are pinned in the vt crate — the shell case keeps its
prompt, the job case re-joins its wrapped line.
Dragging a window slid a small island of text around inside it. The
letterbox lays the surface out at the session's grid whenever that differs
from the pane's, and during a drag it differs for the most ordinary reason
there is: this pane just changed and the daemon has not answered yet. So
the screen stayed at the width the drag started at, centred, and moved as
the pane grew — then snapped at the end. Ghostty has nothing to slide
because its grid grows with the window; leftover pixels are only ever
sub-cell padding (`renderer/size.zig`).

The two reasons the grids can differ are different facts and the pane now
tells them apart. Somebody else is using the session on a smaller screen:
letterbox, or this pane re-wraps bytes that were wrapped for theirs. This
pane was just resized: stand aside — under a policy that follows the
device being used, that resize is about to be granted, so the surface
follows the pane the way it would with no host at all, and the keyframe at
the end is what everyone converges on. A declaration that goes 600ms
unanswered is one the session is not going to grant, and the letterbox
comes back.

With the surface live through the drag, the leading-edge send is pure cost
— a full-screen repaint for every viewer at the moment the drag starts,
buying a width that is already on screen locally. One barrier per drag, at
the end.
The last piece of the drag shake, and the cheap half of cmux's answer.

cmux does not have this bug because it never has two terminal authorities
in one session: a local pane is `ioMode: .exec`, where ghostty owns the
child, the PTY and the terminal protocol, and a remote pane is
`.manualMirror`, where the surface renders somebody else's screen and
encodes input, nothing more. termio's `.inMemory` is neither — libghostty
is a full terminal on this end while termiod runs the authoritative one on
the other, and the two were free to disagree about layout.

So the pane keeps the mirror's discipline with the API we have: the
surface is laid out at the grid the session actually is, and its own grid
changes only when the host's does. The previous commit let it follow the
pane through a drag, which is one authority too many — it re-wrapped
locally on the way and again from the keyframe at the end.

What made a pinned surface look wrong was the centring, not the pinning: a
centred island moves as the pane grows. It is anchored now, which is what
tmux and screen do with space they cannot fill — the text stays where it
is and only the empty area changes.

`viewportPending` goes with it. It existed to suppress the letterbox
during a drag, and there is nothing left to suppress.
The rewrap this added yesterday never fired for the sessions that need it.
It asked `foreground.job` — "is a job running under the shell" — and a
termio agent session is spawned as `zsh -ilc exec claude`, where `exec`
replaces the shell image. The child *is* the agent, its pgid is the
session's own, and the answer was always no. Every agent session kept the
truncating resize, which is the one that leaves a dragged window looking
mangled.

The question was never job control. It is whether the thing on screen
answers SIGWINCH with width-relative cursor arithmetic, and only shells
do: a shell moves the cursor up a row count computed from the old width
and repaints its prompt there, which a rewrap invalidates. An agent TUI, an
editor and a pager all repaint from their own model.

So the foreground's argv0 is matched against a closed set of shell names,
login `-` stripped, and everything else gets the rewrap every terminal
these programs were written against would give them. Name matching is the
wrong instinct for agents, whose set is open; it is the right one for
"programs that redraw a prompt from an old width", which has not grown in
decades. Unreadable foreground means shell, because truncating is the
conservative answer and the one that shipped.
…ands once

The trace of a real drag showed a repaint request after every single
resize — a second full-screen paint each time, and the flicker.

The ordering is why. A resize is a barrier: the daemon quiesces, resizes,
and pushes a fresh keyframe. That keyframe reaches the client before the
next layout pass, so a surface still laid out at the session's *old* grid
parses it at the wrong width, paints it mangled, and then has to ask for
another one once it catches up. Two repaints per resize, the first of them
wrong.

A pane whose own declaration is in flight already knows the grid the
keyframe will be at, so its surface follows the pane rather than the
session and is the right size before the frame arrives. The letterbox
still applies whenever the difference is somebody else's — a phone on the
same session — because there the pane genuinely must not re-wrap bytes
that were wrapped for their screen.

The anchoring stays. Centring is what made a pinned surface look like it
was sliding, and a pane that pads to the right and bottom is what tmux and
screen do with the space.
The surface followed the pane from the first pixel of a drag, so it
re-wrapped the old screen once per frame at widths nothing had ever been
drawn for. Correct at the end, a mess the whole way there.

The pane may lead its own resize — that is what makes the keyframe land on
a surface that is already the right size — but only from the moment the
declaration is actually written. Before that the pane is still moving and
the coalescing window has not closed, and the session's grid is the last
width anything was drawn for. Holding the surface there is what tmux and
screen show while a window moves: the text stays put and the padding
grows.

So the pending flag is raised in the send rather than in the setter. One
line of *when*, and the two halves of the drag stop fighting: frozen while
it moves, leading the instant the resize goes out.
Dragging stopped resizing the terminal at all, and the regression is mine.
The letterbox used to be gated on `!isWriter`, so the pane being typed in
never letterboxed. Regating it on "the session's grid differs from this
pane's" is right under a size policy and wrong without one: an older
daemon — a VPS that has not been redeployed — reads a resize as "set the
PTY size" from the writer, so the pane's own grid *is* the session's. A
difference there is not another viewer, it is a declaration that host will
never answer, and pinning the surface to it freezes the terminal for good.

The handshake already says which kind of host this is (`viewport`), so the
pane asks before it letterboxes at all.

The trace lines now name their session. Four links log into one stream and
the bursts read as a loop until you can tell them apart.
The size assertions in section 2 still described smallest-wins, which
this branch replaces: they read a second screen attaching as something
that must not move the size, and typing as something that must not move
it either. Both are uses now, so both moved it and the suite was red.

Rewritten to the policy the Rust and Swift tests already pin — attaching
sizes the session to the screen it was opened on, typing brings it back
to the screen being typed on, and a departure leaves it to the survivor.
Polled through wait_for_size rather than slept past a fixed window, and
the first screen's size is settled before the second attaches: a client
handed the token re-declares its own viewport, and that declaration has
to land before the next attach or the two arrive out of order.
@jiweiyuan
jiweiyuan force-pushed the feat/size-follows-the-device-in-use branch from 31d03e0 to d5b6d84 Compare September 2, 2026 10:00
@jiweiyuan
jiweiyuan merged commit 685133b into main Sep 2, 2026
7 checks passed
@jiweiyuan
jiweiyuan deleted the feat/size-follows-the-device-in-use branch September 2, 2026 11:06
MichaelC001 pushed a commit to MichaelC001/termio that referenced this pull request Sep 3, 2026
Section 12. The reporter's screenshot is an 82-column Claude Code screen
rewrapped to 66, decoded to the column against the real binary's own
output, which is the observation this file had been asking for since it
was opened.

Also records what a child does with a SIGWINCH, measured rather than
assumed; the two fixes and the hole the first of them opened; and the
direction asymmetry the letterbox produces.

Refs: termio-sh#591
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant