From 164e242d7059a3a8154e7d5bf2a280cf8e8642e3 Mon Sep 17 00:00:00 2001 From: superkim0610 Date: Mon, 27 Jul 2026 22:34:04 +0900 Subject: [PATCH] fix: give a new PTY the terminal's real size, not the 80x24 spawn default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal the user never happened to resize by hand kept its PTY at 80x24 for its entire life. The on-screen grid looked right, but the shell wrapped at 80 columns and any TUI started in it drew its frame to 80 — a restored Claude Code session rendered its welcome banner into the left ~75% of the panel with dead space beside it. Nudging the panel border by a pixel fixed it permanently, which is what made the bug look cosmetic. getOrCreate registers the registry entry BEFORE awaiting the spawn, so that concurrent callers share one object. That also means attach() can — and in practice does — fit the xterm to its real container while the spawn is still in flight. Those fits call terminal.resize(), which fires onResize before wireTerminalListeners has attached the listener that forwards it to the PTY, so the size is applied to xterm and dropped for the PTY. Nothing corrects it afterwards, because fit() only resizes when the size CHANGES: the grid already matches the container, so every later fit is a no-op and the PTY never hears about it. Instrumenting both ends made the ordering unambiguous — six resizes logged against an unwired terminal, then the listeners attaching to a terminal already at 107x27, and zero resize IPCs for the whole restore. Push the terminal's current size once, right after the listeners are wired. The guard keeps it silent when the terminal really is still at the spawn size, so no pointless SIGWINCH reaches an already-correct PTY. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WsMgXwPNJJ4SgXgRff5wJk --- .../lib/terminal/terminalLifecycle.ts | 16 +++++ .../lib/terminal/terminalRegistry.test.ts | 62 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/renderer/lib/terminal/terminalLifecycle.ts b/src/renderer/lib/terminal/terminalLifecycle.ts index e5468d14..9cee6a8e 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.ts @@ -337,6 +337,22 @@ export async function getOrCreate(panelId: string, opts: CreateOpts): Promise ({ default: { warn: () => {}, info: () => {}, error: // a pre-existing ptyId. The bug manifests when a leaked pending-transfer // causes a fresh getOrCreate to silently go down the reconnect path. const terminalCreate = vi.fn(async () => 'pty-fresh') +// Every winsize pushed to the PTY. Used to pin that a terminal fitted while the +// spawn was still in flight still gets its real size through. +const terminalResize = vi.fn() const panelTransferAck = vi.fn(async (_id: string) => undefined as undefined) // Process-wide WebGL grant broker (main-side); default to granting so the // attach() upgrade path runs. Individual tests can override the resolved value. @@ -136,6 +139,7 @@ beforeEach(() => { settingsState.terminalContrast = 4.5 settingsState.terminalOptionIsMeta = true terminalCreate.mockClear() + terminalResize.mockClear() panelTransferAck.mockClear() webglRequestGrant.mockClear() webglRequestGrant.mockImplementation(async () => true) @@ -149,7 +153,7 @@ beforeEach(() => { value: { terminalCreate, terminalWrite: vi.fn(), - terminalResize: vi.fn(), + terminalResize, terminalKill: vi.fn(async () => undefined), onTerminalData: vi.fn(() => () => {}), onTerminalExit: vi.fn(() => () => {}), @@ -821,3 +825,59 @@ describe('process-wide WebGL context grant lifecycle', () => { terminalRegistry.dispose('panel-reset') }) }) + +// Regression: a terminal the user never resizes by hand keeps its PTY at the +// 80x24 spawn default forever, so the shell wraps at 80 columns and any TUI +// started in it draws its frame to 80 — while the on-screen grid looks correct. +// +// Root cause: getOrCreate registers the entry BEFORE awaiting the spawn (so +// concurrent callers share one object), which lets attach() fit the xterm to its +// real container while the spawn is still in flight. Those fits call +// terminal.resize(), firing onResize before the listener that forwards it to the +// PTY exists — the size is applied to xterm and dropped on the floor for the +// PTY. Nothing corrects it later because fit() only resizes on a CHANGE. +// +// Contract: once the spawn resolves, the PTY must be told the terminal's actual +// size. +describe('PTY adopts a size the terminal reached during spawn', () => { + it('pushes the real size once the spawn resolves', async () => { + const { terminalRegistry } = await import('./terminalRegistry') + + // Hold the spawn open so we can fit the terminal mid-flight, exactly as + // attach() does while getOrCreate is still awaiting. + let releaseSpawn!: (id: string) => void + terminalCreate.mockImplementationOnce( + () => new Promise((resolve) => { releaseSpawn = resolve }), + ) + + const pending = terminalRegistry.getOrCreate('panel-race', { workspaceId: 'ws-1' }) + + // getOrCreate awaits settingsGet before terminalCreate; drain the queue so + // the spawn is genuinely in flight when we fit. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The entry is already registered even though the spawn hasn't resolved. + const entry = terminalRegistry.getEntry('panel-race')! + expect(entry.terminal.cols).toBe(80) + entry.terminal.resize(120, 40) // what safeFit() does — no PTY listener yet + + releaseSpawn('pty-fresh') + await pending + + expect(terminalResize).toHaveBeenCalledWith('pty-fresh', 120, 40) + + terminalRegistry.dispose('panel-race') + }) + + it('stays quiet when the terminal is still at the spawn size', async () => { + const { terminalRegistry } = await import('./terminalRegistry') + + // Never fitted (panel not laid out yet): the PTY was created at 80x24 and + // is already correct, so re-sending it would be a pointless SIGWINCH. + await terminalRegistry.getOrCreate('panel-norace', { workspaceId: 'ws-1' }) + + expect(terminalResize).not.toHaveBeenCalled() + + terminalRegistry.dispose('panel-norace') + }) +})