Resize a Terminal and Your Coding Agent Gets SIGWINCH. What Redraws, and What Breaks
Book: AI That Acts
The series: AI in TypeScript - 5 books, from your first LLM call to agents in production - all five here
My project: Hermes IDE | GitHub - an IDE for developers who ship with Claude Code and other AI coding tools
Me: xgabriel.com | GitHub
You narrow a pane to make room for a second agent, scroll up to reread the plan the first one wrote, and the same answer is there three times, each copy wrapped at a different width.
That report sits in the Claude Code tracker as issue #51828, opened on April 22, 2026, labelled has repro, and reproduced by commenters on macOS, Linux with tmux, Windows Terminal, and xterm.js under tmux.
Nothing in that bug is exotic. It falls out of a chain of small, documented steps that run every time a terminal changes size. Once you can see the chain, you can tell which part of a resize glitch belongs to the agent, which to the terminal, and which to the window around them.
The chain: one ioctl, one signal, one repaint
A terminal emulator holds the master side of a pseudo-terminal. Your shell, and any agent you start from it, holds the other side. When you drag a split, the emulator works out the new rows and columns and tells the kernel with the TIOCSWINSZ ioctl.
node-pty, which the README says powers VS Code's terminal among others, does that in its PtyResize function: one ioctl(fd, TIOCSWINSZ, &winp) call. The kernel stores the size in a struct winsize with ws_row and ws_col fields.
Then, per the ioctl man page, "When the window size changes, a SIGWINCH signal is sent to the foreground process group." The default action for SIGWINCH is to ignore it (signal(7)), so a program that never asked about its size keeps running, unaware. A program that does care reads the new size back with TIOCGWINSZ and redraws.
That last step is where everything interesting happens, because "redraw" means something very different depending on which screen the program is drawing on.
Emulator, kernel, foreground process: the size travels down, the signal comes back up as a repaint.
Watch it happen with three commands
You can see each link of the chain from a shell. First, ask the kernel what it thinks the size is. stty size prints "the number of rows and columns according to the kernel":
stty size
Run it, resize the window, run it again. The two numbers change because the emulator told the kernel, not because stty measured anything. You can prove the kernel just stores what it is told: stty cols 40 sets the column count by hand, and the next stty size reports 40 even though your window is wider. Resize the window and the emulator overwrites it again.
Next, catch the signal. Save this as winch.sh and run it with bash winch.sh:
#!/usr/bin/env bash
# Resize the window while this runs. Ctrl+C to stop.
trap 'echo "WINCH: $(stty size)"' WINCH
echo "start: $(stty size)"
while true; do sleep 1; done
Each resize prints a line. The lines can trail the drag by up to a second, and that delay is documented: the bash manual says that if bash "is waiting for a command to complete and receives a signal for which a trap has been set, the trap will not be executed until the command completes" (bash(1), SIGNALS). Here the command is sleep 1.
If your agent is a Node program, the third one is the version it actually sees. Node emits a 'resize' event on process.stdout whenever its columns or rows change, and the process docs say SIGWINCH itself "is delivered when the console has been resized." The script listens for both. Save as winch.mjs:
// node winch.mjs, then drag the pane. Ctrl+C to stop.
const out = process.stdout;
if (!out.isTTY) {
console.error("stdout is not a TTY, run it in a terminal");
process.exit(1);
}
let last = `${out.columns} x ${out.rows}`;
console.log(`start ${last}`);
process.on("SIGWINCH", () => {
console.log(`SIGWINCH ${new Date().toISOString()}`);
});
out.on("resize", () => {
const now = `${out.columns} x ${out.rows}`;
console.log(`resize ${last} -> ${now}`);
last = now;
});
process.stdin.resume(); // keep the event loop alive
Drag a split slowly and count the lines. How many events one drag produces depends on your terminal, not on Node, and a full-screen agent may repaint on each one.
On Windows there is a caveat: the Node process docs say SIGWINCH is only delivered "on write to the console when the cursor is being moved, or when a readable tty is used in raw mode."
Two screens, two kinds of breakage
Terminals have two screen buffers. The normal one keeps scrollback. The alternate one, entered with the xterm private mode 1049, has none. The xterm control sequences describe CSI ? 1049 h as: save the cursor, "switch to the Alternate Screen Buffer, clearing it first." vim, less and htop live there. Try it:
printf '\e[?1049h\e[H' # save cursor, switch, clear
seq 1 5
sleep 3
printf '\e[?1049l' # normal screen, cursor back
The numbers vanish when it switches back, and your old screen returns untouched. Nothing from the alternate screen reaches scrollback.
That split decides what a resize does. xterm.js is the library behind the VS Code integrated terminal. Its buffer set builds the alternate buffer with scrollback turned off ("The alt buffer should never have scrollback"), and its buffer code only enables reflow when the buffer has scrollback. So on the normal screen the terminal rewraps old lines for you. On the alternate screen it does not, and the program has to repaint every cell itself.
On the normal screen, hard line breaks break reflow. A terminal can rejoin a line it wrapped itself. It cannot rejoin a line the program broke by writing a newline: the terminal marks the rows it wrapped itself, and a newline from the program looks the same as one the program meant.
Commenters on #51828 describe Claude Code's classic renderer as doing this: it wraps output at the current width, then on resize moves the cursor up by the previous frame's height and redraws. Cursor-up stops at the top of the visible screen. When the frame is taller than the viewport, the rows already pushed into scrollback can't be reached, the new frame lands below them, and a resize, narrowing especially, can leave another copy behind. That analysis comes from the reporters, backed by byte-level traces, not from Anthropic, and the issue is still open with no linked fix.
On the alternate screen, you lose the terminal's own history. Claude Code's fullscreen rendering draws on the alternate screen "like vim or htop" and only renders visible messages. The docs are clear about the cost: Cmd+f and tmux search "don't see the conversation because it lives in the alternate screen buffer, not the native scrollback", so the app ships its own scrolling, its own search (Ctrl+o then /), and a key ([) that writes the transcript back into native scrollback on demand.
The /tui fullscreen command switches renderers mid-conversation, and /tui default goes back. The #51828 thread lists "tui": "fullscreen" as the workaround that removes the duplication.
Neither choice is wrong. Normal screen keeps your terminal's search and selection and pays for it in reflow bugs. Alternate screen repaints cleanly and pays for it by rebuilding scrollback inside the app.
The third layer: the window around the terminal
There is one more party in a resize, and it is the one you are dragging. An IDE that hosts terminals has its own viewport over the terminal's scrollback, and a resize moves the ground under it: the row count changes, reflow may rewrite line positions, and an agent on the normal screen may be repainting at the same moment. If you were scrolled up reading when that happens, the host has to decide where your view should end up.
I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent (Claude Code, Codex, Antigravity CLI and the rest) in a real terminal running the agent's own interface, with split panes and terminals that auto-fit to the window, which means it sits right on this layer.
The 2.3.4 release on October 15, 2026 fixed exactly this: "Scrolling back through a terminal is no longer interrupted when the window resizes." The same release fixed "A session pane no longer stops working at some window widths," and folds the bars above and below a session into its header in short panes so the agent gets more rows.
Hermes 2.3.4: Codex and Claude Code in two split panes, each in its own hermes/ worktree, with status, context use, cost and branch in the sidebar.
That fix does not touch the agent's renderer. If an agent on the normal screen re-emits its transcript on resize, the duplicate rows are real bytes in scrollback, and no host can tell them from output the agent meant to print. Hermes is source-available under BSL 1.1 and runs on macOS, Windows and Linux. Download it here or read the code on GitHub.
What to do when a resize goes wrong
Work down the chain:
- Wrong size, or none. Run
stty sizein the same pane. If it disagrees with what you see, the emulator never sentTIOCSWINSZ, or something in between (tmux, an SSH hop) did not pass it on. - No repaint. Run
winch.mjsin that pane. If no resize lines appear, the signal is not reaching the foreground process. On Windows, see the caveat above: this script may stay silent even when resizing works. - Copies in scrollback after a resize. That is a normal-screen renderer re-emitting its frame. For Claude Code, try
/tui fullscreenand see if it goes away. - Garbled leftovers. In fullscreen mode, Claude Code's docs say
Ctrl+Lredraws without losing the conversation, andCLAUDE_CODE_ALT_SCREEN_FULL_REPAINT=1repaints every cell each frame for terminals such as Windows Terminal that mishandle incremental writes. - Your scroll position jumps. That is the host's viewport, not the agent. Update the host before blaming the agent.
Keep winch.mjs around. The next time an agent smears its output across scrollback, it tells you in seconds whether the signal ever arrived.
If this was useful
The winch.mjs above is the start of every TypeScript agent that lives in a terminal: it has to know its size, react to events and keep running. AI That Acts is where I take that further, from tool calling and plain functions to a first working agent in TypeScript that decides what to run next. It is book 3 of AI in TypeScript, a five-book series that runs from your first LLM call through to agents you can leave running in production.
- Book: AI That Acts
- The series: AI in TypeScript - 5 books, from your first LLM call to agents in production - all five here
- My project: Hermes IDE | GitHub - an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Comments
No comments yet. Start the discussion.