Skip to content

fix(core): refresh dimensions after resize debounce - #1363

Closed
dajiaohuang wants to merge 4 commits into
anomalyco:mainfrom
dajiaohuang:terminal-resize-sampling
Closed

dajiaohuang wants to merge 4 commits into
anomalyco:mainfrom
dajiaohuang:terminal-resize-sampling

Conversation

@dajiaohuang

@dajiaohuang dajiaohuang commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • refresh cached TTY dimensions when a debounced SIGWINCH is handled
  • sample the refreshed dimensions when the debounce expires
  • use stdin as the size source when it is the TTY and stdout is piped
  • cover stale cached dimensions and piped-stdout setups with regression tests

Browser-hosted PTY bridges can deliver a resize signal before the runtime's cached stdout.columns and stdout.rows reflect the new kernel window size. The existing resize path captured those cached values immediately, so initial attach and shrink events could keep rendering at the previous width.

OpenTUI also always read dimensions from stdout, but some launchers pipe stdout while keeping stdin attached to the controlling terminal. In that setup the resize signal arrives, yet stdout has no terminal dimensions. The resize handler now refreshes and samples the active TTY stream after the debounce expires.

This keeps the existing debounce behavior and is related to anomalyco/opencode#42225.

Closes #1344

Testing

  • bun run test:js (5,397 passed, 23 skipped)
  • bun run test:js:node with Node.js 26.4.0 (4,664 passed, 6 skipped)
  • bun run build:lib
  • bun run fmt:check
  • bun run lint
  • Linux PTY reproduction covering initial attach, grow, and shrink

@dajiaohuang
dajiaohuang force-pushed the terminal-resize-sampling branch from 7f983e5 to 28c0435 Compare September 4, 2026 06:51
@dajiaohuang

dajiaohuang commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and kept the upstream invalid-dimension guard after refreshing the chosen TTY stream (28c0435). Added a regression for zero dimensions. Both resize/console suites pass: 87 tests; core typecheck and library build pass.

The full core suite initially reported 5,579 passing tests, 36 skipped and one missing generated parser.worker.js asset. After restoring the local bunx launcher and running the library build, the previously failing Node asset test file passes (2/2). No source change was needed for that setup failure.

@AmethystLiang

Copy link
Copy Markdown

Another data point from an Electron + xterm.js host, in case it helps this land.

Environment: Orca (Electron, xterm.js terminal over a local PTY), macOS 15 / Apple Silicon, opencode 1.18.31 (current latest). Users hit this constantly, because switching worktrees/tabs in the IDE changes the pane geometry while the TUI is in the background.

What I measured. I drove a live opencode session over CDP and resized only the PTY, holding the visible xterm grid fixed, to isolate the two directions:

PTY change result
212 → 180 cols (shrink) opencode re-laid out at 180, but cells from the previous 212-col frame stayed painted past the new right edge (visible fragments: ord reply: ok, okens, de 1.18.31)
180 → 212 / any grow full repaint, screen clean every time

The recovery asymmetry matches what #42225 describes: growing is the only thing that reliably produces a complete frame. Every workaround our users have found (drag a panel wider, change tab zoom) is a grow in disguise.

One question about the scope of this fix. Refreshing the cached dimensions before sampling should fix the case where the re-layout happens at the wrong width. It is less clear to me whether it also covers the case where the re-layout happens at the correct, smaller width but the region that is no longer part of the grid is never cleared. If OpenTUI's damage tracking only repaints cells inside the current grid, a shrink would still leave the out-of-range cells from the larger previous frame on screen. If that is already handled elsewhere, ignore this; if not, a full clear on shrink may be needed alongside the dimension refresh.

Happy to test a build against our host and report back. We have a reproducible harness on this side.

@dajiaohuang

Copy link
Copy Markdown
Contributor Author

Thanks — that distinction is valid. I checked the current head: processResize now re-reads the selected TTY before re-layout, and the native resize() clears/reinitializes the logical buffers so every cell inside the new viewport is repainted. It does not, however, issue an explicit terminal scrub for cells that were outside a narrower viewport.

That matches the outcome of #1240: forcing a full logical repaint was tested and the artifact still reproduced, so a remaining shrink-only symptom would need a separate physical-terminal scrub rather than another buffer repaint. Please try this head with your harness; if the out-of-range fragments remain, the next focused change should target the ANSI scrub on width shrink. I have not changed this PR speculatively.

@angeloper86

Copy link
Copy Markdown

Cross-referencing some user-side results on the real opencode v2.0.18 binary: anomalyco/opencode#42225 (comment)

Summary for this branch:

  • opencode v2.0.18 resolves @opentui/core 0.5.12 (workspace catalog), while this branch's packages/core/package.json is at 0.5.10 — it is behind the core that ships in the affected client.
  • In the shipping runtime (Bun 1.4.2) process.stdout._refreshSize exists, so the refresh call this PR adds is available; availability is not the blocker.
  • On the real binary the frozen-input symptom reproduces deterministically (large paste + resize burst in a PTY harness) and manifests as the TUI's JS loop stalling: the process's timers stop firing entirely after the trigger (250 ms sampler / 4 s heartbeat go silent), renderer geometry and Bun's cached stdout dims stay stale, and subsequent resizes have no effect.
  • Installing this PR's handler semantics at runtime (fresh dims from stty, preserved 100 ms debounce, processResize) did not recover that state, and a periodic stdout._refreshSize() + renderer.processResize() + renderer.resize() rescue never got to run because timers had stopped.

Read on its own that is a good argument for refreshing dims after the debounce (the stale read is real), but the frozen case we observe seems to sit below the handler. Harness (PTY driver + in-process probe plugin): https://gist.github.com/angeloper86/15f078cfb853b531dc0f89b77d4795d7

@dajiaohuang

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed harness and version correction. I checked this branch: it is still on @opentui/core 0.5.10, and the change refreshes and samples the selected TTY after the debounce; the added tests cover stale dimensions, piped stdout, and invalid dimensions. Your v2.0.18 result is a different signal: the probe timers stop, and injecting the handler semantics at runtime still freezes. That experiment did not run a build of this PR head, so it does not establish whether the checked-in change addresses the freeze. I’m keeping this PR limited to the stale-dimension path instead of adding an unlocalized event-loop change. If you can run the harness against a build of this head (or a rebased equivalent), that will tell us whether a separate OpenTUI investigation is needed.

@FireChickenMP4

Copy link
Copy Markdown

Thanks for keeping this scoped to the stale-dimension path. That part is still needed.

While reproducing on opencode 2.1.2 / Windows 11 we found a second half that sits right
next to it, and I think it is worth handling together:

  • A terminal signal can arrive after the terminal has reflowed or cleared its cells, but
    without the applied grid changing. Re-sampling alone is not enough there, because the
    re-sampled size can legitimately equal the current geometry: processResize then
    returns early and the screen stays blank.
  • Concretely: leaving fullscreen can leave the composer off-screen; dragging the window
    width restores it; dragging back to the previous width breaks it again.

So I ported your approach to the current pendingNativeResize path (re-sample at debounce
time, including stdout._refreshSize?.()) and added one more bit: a terminal-driven
resize forces a full repaint even when the refreshed size equals the current one.
Both are in #1738.

Tests, run locally on Windows 11 + Bun 1.4.2:

bun test src/tests/renderer.console-startup.test.ts
 81 pass
 0 fail

including "repaints when a terminal resize signal reports the current size" and
"re-samples the terminal size after the resize debounce"; the existing "ignores a
size-unchanged resize signal without an intermediate size change" (programmatic path)
still passes.

If you would rather keep #1363 as the single place for the re-sample change, I can drop
that half from my PR and keep only the repaint fix. Your call.

@dajiaohuang

Copy link
Copy Markdown
Contributor Author

Thanks for offering to keep the scopes clear. Comparing the diffs, I recommend keeping both pieces together in #1738 for the current renderer path: #1363’s resampling change uses the older handleResize() path, while #1738 is based on a later main snapshot and ports resampling into pendingNativeResize, then adds the same-size repaint. Dropping resampling from #1738 would leave that current-path fix dependent on #1363 being rebased/ported first. The two failure modes are conceptually distinct, but I would avoid splitting this implementation across the two PRs. This is my contributor preference, not an upstream approval or merge decision.

@FireChickenMP4

Copy link
Copy Markdown

Makes sense — I'll keep the resampling and the same-size repaint together in #1738. Thanks for checking.

@simonklee

Copy link
Copy Markdown
Member

This pull request is replaced by #1738. #1738 adds the same stdout size refresh after the resize debounce. It uses the current pendingNativeResize path on main, and it also adds a repaint when the size does not change. This branch changes the older handleResize() path. Main has changed that path since then.

@simonklee simonklee closed this Oct 23, 2026
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.

SIGWINCH-based window resize silently breaks when process.stdout is not the user's real terminal (e.g. wrapped by 1Password's op run)

5 participants