Skip to content

TUI does not re-layout on terminal shrink (only on grow), leaving stale width #42225

Closed as not planned

Description

@HermanShi

Summary

When the terminal is resized, the opencode TUI re-layouts on grow but not on
shrink, and not on the initial attach. The drawn width stays at its previous
value, so the TUI either leaves blank space or overflows the terminal.

Measured in a browser-hosted terminal (xterm.js over a WebSocket PTY, opencode
1.18.16):

terminal columns columns opencode actually draws result
100 (initial) 78 under-fills — blank space on the right
119 (grown) 117 correct, follows the resize
68 (shrunk) 117 stale — overflows the terminal

The resize does reach the process

This is not a missing-notification problem. The chain was verified layer by layer:

  • the terminal element resizes
  • xterm.js recomputes its grid (26 -> 15 -> 31 rows)
  • the frontend sends the new size over the WebSocket:
    {"type":"resize","data":{"cols":68,"rows":27}}
  • the PTY receives it and delivers SIGWINCH — a plain bash on the same host over
    the same transport re-wraps correctly, so the signal is being delivered

Only the last step fails: opencode receives the new size and re-layouts on grow, but
keeps the old width on shrink and on the initial draw.

Workaround

Forcing a redraw inside the TUI (e.g. Ctrl+L) restores the correct layout, which is
consistent with "the size is known, but no re-layout is scheduled".

Environment

  • opencode-ai 1.18.16 (release notes for 1.18.17 and 1.18.18 contain no
    resize-related changes, so this is likely still present)
  • Linux x86_64; the terminal is xterm.js in a browser, over a WebSocket PTY bridge
  • Reproduces on every attach; not specific to a model or provider

Activity

  1. fluffyspace commented on Aug 13, 2026

    @fluffyspace

    Same root cause here, confirmed from a second browser-hosted deployment (xterm.js over a WebSocket PTY bridge, opencode 1.18.18 on Linux). Just wanted to add the concrete symptom + evidence and confirm the workaround, in case it helps the fix land.

    Symptom: the stale width doesn't only leave blank space/overflow — it garbles the sidebar and status bar, because they're drawn at the stale width's absolute columns. In a real session the sidebar showed:

    • dropped letters: Host client → H s client, local + remote backend → l cal + remote back nd
    • a token counter split across rows with digits at every-2-column positions, e.g. \x1b[6;201H 9, \x1b[6;203H 8, \x1b[6;205H 3
    • the status-bar cwd /home/lenovo/ai-remote-hub drawn as [49;4H /home/lenovo/a + [49;19H -r + [49;22H mote-hub (columns 18 and 21 never written)

    All of it is in the raw PTY output — the terminal emulator and transport relay it faithfully. It develops over time (prompts + session switches = resizes), never on the welcome screen.

    Workaround confirmed + automated: injecting Ctrl+L (\x0c) into the PTY after each geometry change forces opencode to repaint at the freshly-applied size and clears the garble every time. We shipped that server-side for our opencode sessions and it resolves it reliably.

    Impact: this hits any deployment embedding opencode in a browser/web PTY (xterm.js, hterm, VS Code web, tmate-style bridges), and "shrink" happens constantly there (tab switches, keyboard, resize). A maintainer fix to re-layout on shrink / initial attach would be very welcome — happy to provide more trace data.

  2. fluffyspace commented on Aug 13, 2026

    @fluffyspace

    Correction to my earlier comment: I claimed injecting Ctrl+L after resizes resolves it reliably — that did not hold up on opencode 1.18.18. Measured against a live session:

    • sending \x0c (Ctrl+L) into the PTY produces zero bytes of output — opencode ignores the unbound key entirely, no repaint happens
    • Ctrl+P (command palette) does trigger a full re-render, but it re-renders at the stale width: after shrinking the PTY to 150 cols the redraw still draws at columns 197–230, so the sidebar/status stay wrong
    • the welcome/first screen does re-layout correctly on shrink, but the full conversation view does not

    So an input-injected workaround won't work in 1.18.18 — the TUI needs to actually re-read its size and re-layout on SIGWINCH (or a real terminal redraw), not just get a keystroke. Only restarting the session reliably recovers it. This looks like the more general form of #42225 (no re-layout on shrink), so fixing it properly is worth it — happy to provide the capture data.

  3. fancive commented on Aug 13, 2026

    @fancive
    Contributor

    I can take this. I’ll first add a focused regression test for terminal shrink/re-layout, then keep the fix scoped to the resize path. Could you assign this issue to me?

  4. dajiaohuang commented on Aug 13, 2026

    @dajiaohuang

    I reproduced this at the OpenTUI layer, including initial attach, grow, and shrink, and opened an upstream fix:

    anomalyco/opentui#1363

    The failing path is OpenTUI's SIGWINCH debounce retaining cached stdout dimensions when the signal arrives before the runtime cache reflects the new PTY size. The patch refreshes and samples the TTY dimensions when the debounce expires before triggering re-layout. OpenCode itself did not need a separate TUI scheduling change in this reproduction.

  5. fancive commented on Aug 13, 2026

    @fancive
    Contributor

    PR opened: #42330. @HermanShi @fluffyspace, if you can still reproduce this setup, please test the fix and add a 👍/❤️/🎉/🚀 reaction to the PR body so it stays visible to the cleanup automation.

  6. 4elovekyc commented on Aug 20, 2026

    @4elovekyc

    Same bug on Windows 11 with Orca IDE (xterm.js terminal, same as VS Code), opencode-ai 1.18.18 (npm global install).

    Repro pattern: artifacts appear right after a task finishes / final output is rendered — leftover overlapping panels, garbled sidebar/status bar. The only reliable recovery is changing the tab zoom/scale in Orca (forces a grow resize → re-layout). Restarting the opencode session also recovers.

    Additional data point for the maintainers:

    • Terminal renderer is NOT the cause: I switched Orca's terminal from WebGL to DOM renderer (GPU Acceleration → Off, xterm.js DOM renderer) and the artifacts still appear. This is the OpenTUI stale-width/shrink issue, independent of the renderer.
    • The shrink happens constantly in Orca (tab switches, panel layout changes after session end), so the bug is hit on every session completion.

    Voting for the fix in PR #42474 / opentui PR #1363 to land — this makes opencode unusable-ish inside Orca on Windows.

  7. angeloper86 commented on Sep 28, 2026

    @angeloper86

    Still reproduces on the V2 TUI (v2.0.18) — macOS 26.6.2 + Ghostty 1.3.1 — and the frozen state is worse than stale width: typed input stops being drawn

    Adding a macOS field report (native terminal, not a browser PTY bridge). On the V2 TUI the shrink case doesn't just leave stale geometry: after the layout gets pinned to the old size, the prompt/input area ends up drawn outside the visible viewport, so typing produces nothing visible. The process stays alive and keeps receiving keys. For a non-technical user the only reliable recovery is killing the TUI client and resuming the session.

    Environment

    • opencode version: 2.0.18 (opencode --version → v2.0.18; V2 channel, latest published at the time of writing)
    • OS: macOS 26.6.2 (build 25G83), Darwin 25.6.0 arm64 (Apple M2)
    • Terminal: Ghostty 1.3.1 (TERM_PROGRAM=ghostty, TERM=xterm-256color, COLORTERM=truecolor)
    • Shell: /bin/zsh
    • Install: ~/.opencode/bin/opencode (v2 binary), background service healthy (opencode service status → http://127.0.0.1:49374)
    • Active TUI plugins: local plugin bridges under ~/.config/opencode/plugins/ (context-usage, rundev). They are loaded in the repro below; the root cause described here is independent of them.

    Reproduction (user flow, intermittent but frequent)

    1. Open Ghostty, cd to a project, launch opencode.
    2. Toggle native macOS fullscreen and work in the session (happens with both short and long sessions).
    3. Leave fullscreen → the window shrinks (also triggers on split, and on drag-to-edge macOS tiling: half/quarter screen).
    4. The TUI keeps the previous geometry. Content is clipped/misplaced and the input line no longer renders. Typing produces no visible characters.
    5. Further resizes, Ctrl+P (command palette), and Ghostty super+shift+e (equalize_splits) do not recover it.
    6. Only restarting the TUI client recovers; the session is intact server-side (opencode -c resumes it).

    Automated reproduction (isolated PTY, same host)

    opencode --standalone in a private PTY with a temporary database (OPENCODE_DB), animations disabled, driven by a Python harness:

    • Resize storm: 170 TIOCSWINSZ events over ~4s while typing, then settle at 62x18. The app kept drawing (2.8 KB of output) but a subsequently typed marker string never appeared on screen.
    • A forced grow re-render resumed drawing immediately.
    • Slow, single-step shrink/grow cycles (including 110x32 → 62x18 bursts) did not reproduce on their own — consistent with the debounce/race, not with a missing-signal problem.

    Root cause

    Matches the analysis already in this issue and opentui#1363: the debounced SIGWINCH path samples cached stdout dimensions that are still stale when the signal arrives ahead of the runtime cache update. Shrink is the worst case because the stale geometry is larger than the real viewport, so content (including the prompt) is positioned off-screen.

    Workarounds measured against the frozen state (least → most disruptive)

    Action Result
    Ghostty super+shift+e (equalize_splits) no effect — only rebalances Ghostty splits, does not repaint the TUI or resend a correct size
    Ctrl+P (command palette) + Esc repaints, but sometimes at the stale geometry; occasionally unfreezes input
    Ctrl+Z (terminal.suspend) then fg full repaint with correct geometry ✅
    Force a grow: cmd+- (smaller font → more columns) then cmd+0 resumes drawing ✅
    pkill -WINCH -x opencode from another tab while the window already has the new size resumes drawing ✅
    Kill TUI client + opencode -c in a new tab always recovers (session survives on the server)

    Impact

    On Ghostty/macOS this is a hard usability break rather than a cosmetic artifact: the session becomes unusable until the client is restarted. The only mitigations that work from inside the frozen TUI are forcing a grow re-layout (font size change) or suspend/resume.

    A standalone reproduction harness (Python, PTY + TIOCSWINSZ) is available on request and can be attached to this issue.

  8. dajiaohuang commented on Sep 28, 2026

    @dajiaohuang

    Thanks for the V2/Ghostty reproduction and the resize-storm measurements. I checked the current OpenCode dev source (1.18.33): it delegates resize handling to @opentui/core 0.4.5 and has no separate SIGWINCH handler. The related OpenTUI PR #1363 remains open; its head refreshes the selected TTY after the resize debounce, and the checks recorded for that head passed. I have not verified your v2.0.18/Ghostty harness against it. OpenCode PRs #42330 and #42474 both closed unmerged. If you can attach the standalone harness or a result against the current #1363 head here, that would show whether the frozen-input case is covered by the dependency fix or needs an OpenCode-specific follow-up.

  9. angeloper86 commented on Sep 29, 2026

    @angeloper86

    Follow-up: harness attached + A/B results — on v2.0.18 the frozen-input case is not a stale-dimensions problem, it is a TUI JS-loop stall

    Thanks for the pointers. Harness: https://gist.github.com/angeloper86/15f078cfb853b531dc0f89b77d4795d7 (PTY + TIOCSWINSZ driver, plus an in-process probe plugin).

    Method

    Against the real v2.0.18 binary (isolated XDG_CONFIG_HOME, temp DB, opencode --standalone, animations: false, only the probe plugin), driving a raw PTY with TIOCSWINSZ. The probe is a CLI plugin using context.renderer that samples every 250 ms:

    • renderer.width/height (the geometry the TUI lays out with),
    • process.stdout.columns/rows (Bun's cached TTY view),
    • stty size (independent source, separate process),
    • plus a passive SIGWINCH counter and optional rescue hooks.

    Scenarios per iteration, each followed by typing a marker and checking whether the TUI draws it: (1) shrink burst 150→62 cols, (2) fullscreen-like grow then shrink, (3) random drag (14 steps), (4) 600-word paste immediately followed by a resize burst (4 ms steps).

    Version corrections

    • v2.0.18's TUI resolves @opentui/core 0.5.12 (workspace catalog at tag v2.0.18), not 0.4.5 — 0.4.5 is the V1 branch (1.18.33) you inspected.
    • opentui#1363 head is based on core 0.5.10 (packages/core/package.json on the branch): behind the core that ships in v2.0.18.
    • process.stdout._refreshSize does exist in the shipping runtime (Bun 1.4.2, node=v26.3.0), so the refresh API the PR uses is available; availability is not the blocker.

    Results

    arm it1–it3 it4 (paste + burst) it5–it8 final explicit grow/shrink markers drawn
    stock (embedded 0.5.12) ok frozen at 56x18 never recovers no recovery 5/8 invisible
    PR-style handler installed at runtime (fresh dims from stty + processResize, same 100 ms debounce) ok frozen at 56x18 never recovers no recovery 3/6 invisible
    periodic rescue (stdout._refreshSize() + renderer.processResize() + renderer.resize()) ok frozen rescue never ran — 3/6 invisible

    The freeze is sticky and identical in all three arms: once triggered, subsequent grow, shrink and drag resizes have no effect.

    What the frozen state actually looks like

    • Renderer geometry stuck at 56x18 while the PTY is 62x18; process.stdout.columns/rows equally stale.
    • The probe's timers stop firing entirely after the trigger (250 ms sampler and a 4 s heartbeat go silent while the run continues ~30 s longer). That is also why the periodic rescue never ran: setInterval callbacks stopped executing, not because the size check failed.
    • SIGWINCH events were still being delivered before the silence.

    So the fragile path does not look like "returns after the debounce and keeps sampling stale dimensions" — that would still tick. The TUI's JS loop appears to wedge under a resize burst while a heavy render is in flight, and no dimension refresh recovers it afterwards.

    Caveats (so this is not over-read)

    • I could not rebuild the shipped binary against the #1363 head; I installed the head's handler semantics at runtime instead (replacing renderer.sigwinchHandler, with stty as the refresh source). It does not reach the failure mode we see (timers stop), so I do not think hydration ordering is the deciding factor, but a true head build is still the only way to fully exclude it.
    • With the runtime patch, SIGWINCH listener count stays at 3 — I removed and replaced the renderer's handler, but cannot prove no other listener still runs the old path.
    • Reproduction is deterministic in this harness but load/timing dependent: gentle single-step resizes alone do not trigger it.

    User-facing symptoms that match this

    The reporter's flow (fullscreen → work → leave fullscreen, sometimes with splits or drag-to-edge) matches scenario 2/4. From the same machine: Ctrl+Z/fg repaints, forcing a grow relayout (font-size change) recovers when the loop still responds, and restarting the TUI client (opencode -c) always recovers — session state is server-side.

    Happy to run a true #1363 build inside this harness if someone can point at a built artifact (or confirm the branch rebased onto 0.5.12+).

  10. dajiaohuang commented on Oct 7, 2026

    @dajiaohuang

    @angeloper86 Incredible investigation — thanks for putting together the harness and running the A/B test! That's a key finding: it's not stale dimensions after all, but the TUI JS loop itself wedging under a resize burst during a heavy render (timers stop firing entirely, which explains why the periodic rescue never ran either). That completely changes where to look — the problem is in the event loop getting blocked, not in dimension refresh. I'll dig into the render path for places where synchronous work or a missing microtask yield could stall the loop under paste+resize load.

  11. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions