Size a session to the device being used - #591
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
force-pushed
the
feat/size-follows-the-device-in-use
branch
from
September 2, 2026 10:00
31d03e0 to
d5b6d84
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
This is tmux's
latest(which tmux ships as its default,resize.c:179, keyed on a storedw->latestpromoted by keypress and client resize), with zellij's refinement that only attachments actually rendering count. What made termio's oldlatestoscillate 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
Pty::resizereadsTIOCGWINSZback and the fault is logged. The child reflows from its ownTIOCGWINSZ, 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 andE resizedcould all describe a screen that does not exist.TerminalGrid.fittingin 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.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 intoUInt16.Verified end to end on a dev build with Claude Code in the pane: the daemon's size and the kernel's
TIOCGWINSZagree, and the pane's viewport never diverged from the PTY across window resizes and sidebar toggles.Release Notes: