Skip to content

fix(tui): coalesce terminal resize frames - #993

Closed
avs-io wants to merge 5 commits into
getagentseal:mainfrom
avs-io:codex/issue-977-resize-debounce
Closed

fix(tui): coalesce terminal resize frames#993
avs-io wants to merge 5 commits into
getagentseal:mainfrom
avs-io:codex/issue-977-resize-debounce

Conversation

@avs-io

@avs-io avs-io commented Aug 13, 2026

Copy link
Copy Markdown
Member

Problem

Terminal font zoom and window-resize bursts caused the interactive dashboard to repaint repeatedly. Even a simple debounce still allowed unrelated React updates during the quiet window to paint a stale-width frame before the final settled layout.

Root cause

Ink observed physical terminal dimensions immediately while CodeBurn separately rerendered after resize events. Width and height could therefore advance through different render paths, and state updates or refreshes could write an intermediate frame against dimensions the terminal had already left.

Change

  • Hold interactive terminal dimensions stable during resize bursts.
  • Suppress synchronized application frames while a resize is pending while preserving imperative terminal-control writes and write completion semantics.
  • Publish one coherent settled resize to Ink, useWindowSize, and the dashboard before requesting the final rerender.
  • Preserve mounted period, view, and logical viewport state.
  • Cancel pending delivery and remove all resize relays during teardown and render failures.
  • Normalize invalid terminal dimensions to safe defaults.

User impact

Zooming the terminal font or rapidly resizing the window now produces one final reflow after dimensions settle instead of repeated stale or partial dashboard redraws. The user remains at the same logical reading position and terminal modes are restored cleanly on exit.

Preservation and out of scope

  • Static and non-interactive output are unchanged.
  • Periodic refresh behavior is unchanged.
  • One final synchronized repaint after settlement remains expected.

Testing

  • Credible RED on the prior candidate: a state update during pending resize wrote an old 100×24 frame followed by a mixed 80×20 prop / 100×24 hook frame.
  • Focused real-Ink suites: 73/73.
  • Full root suite: 2,633 passed, 5 skipped.
  • Serial lock suite: 26/26.
  • Full desktop suite: 468/468.
  • Root, dashboard, and desktop TypeScript checks passed.
  • CLI, dashboard, and desktop production builds passed.
  • Exact-SHA independent spec and hostile standards reviews approved.
  • Real controlling-PTY storm across width/height breakpoints: zero pre-settle bytes, one BSU/ESU transaction, one complete final frame, zero later frames, and clean mouse/alternate-screen teardown.

Fixes #977.

@iamtoruk

Copy link
Copy Markdown
Member

Reviewed against current main (rebased locally; two trivial conflicts). The root-cause analysis is right — Ink registers its own resize handler and repaints per event, so a local debounce alone wouldn't fix #977 — and the frozen-dimensions + private resize emitter part is sound.

One regression that blocks merge as-is: the write() interception that suppresses frames during the burst can drop an update permanently. Ink dedupes at two layers (log-update and Ink.lastOutput); when the proxy swallows a frame, Ink.lastOutput still advances, so if the settled render produces the same string nothing is ever written. That happens whenever a burst nets to no dimension change while a state update lands in the quiet window — font zoom in then back out, drag-and-return, or a spurious SIGWINCH with unchanged dims (tmux/kitty emit these). Repro against the branch:

  • burst of 20 resizes → one paint at final width ✅
  • state update mid-burst, size changes → one frame, new data + width ✅
  • state update mid-burst, burst nets to no size change → zero bytes written after settle
  • spurious resize (identical dims) + state update → zero bytes written
  • no resize → identical to plain render()

app.clear() before the settled rerender doesn't rescue it (Instance.clear() resets log-update's state but not Ink.lastOutput).

Suggested shape: drop the write() interception / finalFramePreamble / suppressingFrame / BSU-ESU handling; keep frozen columns/rows + the private resize emitter + dispose; on settle call app.rerender(...) (Ink recomputes Yoga from the facade's columns), plus app.clear() when the terminal shrank. Accepts one transient old-width frame in the rare state-update-during-burst case, never loses an update, and is ~120 lines smaller in src/ (the EventEmitter-facade fidelity surface — emit, setMaxListeners, eventNames, rawListeners… and its ~150 lines of tests — only Ink and useWindowSize consume this stream, via on/off). Please add the two ❌ cases as regression tests.

Rebase note: tests/dashboard.test.ts on main (#1020's connector test) still passes windowColumns: 120; with this PR's terminalSize rename it fails at runtime (tests aren't type-checked), so update that call when you rebase.

@avs-io

avs-io commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Closing this draft. The write-interceptor shape loses mid-burst state updates when net size is unchanged, so it should not be marked ready.

A smaller #977 (frozen columns/rows, no interceptor) is parked locally. Next PR from this fork is the menubar Keychain credential migrate.

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.

TUI: terminal zoom/resize causes repeated full-screen redraws

2 participants