Skip to content

TUI screen goes completely black when agent starts working (render loop silently stalls) #37803

Closed as not planned

Description

@AH64-dll

Description

After pressing Enter to send a prompt to the agent, the entire TUI goes black while the agent is working. The process is still alive (keyboard input works, modals render correctly on top of the black screen). Switching to another terminal tab and back restores the display. This happens consistently (every session, every prompt), not intermittently.

OpenCode version: 1.18.3
Platform: Arch Linux
Terminal: (add your terminal info)

Steps to reproduce

  1. Run opencode in any project
  2. Enter any prompt and press Enter
  3. As the agent starts working, the screen goes black
  4. Switch to another terminal tab and back — display recovers
  5. Prompt again → screen goes black again

Root cause

The bug is in the bundled @opentui/core CliRenderer render loop. A race condition in the frame scheduling mechanism causes the render loop to silently stop producing frames during bursts of requestRender() calls.

The race condition in detail

The renderer operates in idle mode (_isRunning = false), where frames are only produced on demand via requestRender(). The flow is:

  1. User presses Enter → burst of events → multiple requestRender() calls
  2. First requestRender() passes the guards and calls activateFrame() → loop()
  3. loop() sets this.rendering = true
  4. During loop(), subsequent requestRender() calls hit the this.rendering guard (line 1516) and only set this.immediateRerenderRequested = true
  5. loop() finishes, reads immediateRerenderRequested (line 4478), schedules another loop via setTimeout, and clears the flag (line 4481)
  6. RACE: Between line 4481 (flag cleared) and line 4516 (this.rendering = false in finally), another requestRender() fires — this.rendering is still true → sets immediateRerenderRequested = true again
  7. The finally block (lines 4515–4521) sets this.rendering = false and calls resolveIdleIfNeeded() — but does NOT check immediateRerenderRequested
  8. Control returns to activateFrame(), which clears updateScheduled = false in its finally
  9. The scheduled setTimeout from step 5 fires, calls loop() — if no new requestRender() arrives in time, _isRunning is false and immediateRerenderRequested is false, so the else branch (line 4486–4489) clears the timeout and stops scheduling
  10. Render loop is dead. No more frames are produced. The screen stays black.

Why switching tabs fixes it

Switching terminal tabs sends SIGWINCH, which triggers handleResize() → requestRender(), which wakes up the dead loop.

Code references

All line numbers refer to @opentui/core packages/core/src/renderer.ts v0.4.5:

  • requestRender() (lines 1491–1534): early-return at line 1516 when this.rendering is true, only sets this.immediateRerenderRequested = true
  • loop() scheduling decision (lines 4478–4489): reads immediateRerenderRequested at line 4478, clears it at line 4481 — but a request arriving between 4481 and the finally is lost
  • loop() finally block (lines 4515–4521): sets this.rendering = false but does NOT re-check immediateRerenderRequested
  • activateFrame() (lines 1536–1548): clears updateScheduled = false in its finally — another window where requestRender() is silently dropped (guarded by !this.updateScheduled at line 1521)

Proposed fix

Based on upstream PR anomalyco/opentui#1086 (which was closed without merging — re-submission needed):

In loop()'s finally block, re-check immediateRerenderRequested and re-issue via setImmediate:

} finally {
  this.rendering = false
  if (this._destroyPending) {
    this.finalizeDestroy()
  } else if (this.immediateRerenderRequested) {
    this.immediateRerenderRequested = false
    setImmediate(() => {
      if (!this._isDestroyed) this.requestRender()
    })
  }
  this.resolveIdleIfNeeded()
}

setImmediate (not process.nextTick) is needed because activateFrame() keeps updateScheduled = true until its own finally after loop() returns. process.nextTick fires before microtasks resume — before activateFrame's finally — so requestRender() would be blocked by the !updateScheduled guard at line 1521.

Upstream issue

This is a re-report of the upstream opentui issue:

The fix needs to be applied in @opentui/core, released, and then pulled into the opencode build. Currently opencode v1.18.3 bundles @opentui/core v0.4.5 which has the bug.

Workarounds

  • opencode spawn — runs backend in a separate process from TUI, may reduce the event burst pressure on the render loop
  • Ctrl+P → "Toggle console" — triggers a re-render via the console overlay toggle
  • Resize the terminal window — sends SIGWINCH, wakes the dead loop

Additional note

There is a related opentui issue anomalyco/opentui#1147 ("TextNodeRenderable.requestRender() silently drops renders when transiently detached") with an open fix PR anomalyco/opentui#1173. This could be a compounding factor, causing text content to freeze even when the render loop is technically alive. However, the primary symptom (entire screen black, not just text) matches the render loop stall described here.

Activity

  1. github-actions commented on Jul 19, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is specifically triggered during agent work (not at startup), it may be a distinct race condition as you've described. But linking these for cross-reference may help track down a shared root cause.

  2. AH64-dll commented on Jul 19, 2026

    @AH64-dll
    Author

    Thanks for the cross-references. I checked all three:

    All four issues trace back to the same fragile render-loop scheduling in @opentui/core CliRenderer — the gap between flag checks and the finally block where re-render requests are silently dropped.

  3. AH64-dll commented on Jul 19, 2026

    @AH64-dll
    Author

    Fix PRs are up:

    To unblock yourself in the meantime, you can:

    npm install github:AH64-dll/opentui#8989f078747116b42dda6af1fe1549e9de1e5cc8
    

    or apply the one-line fix manually to node_modules/@opentui/core/renderer.js:

    In the loop() function, find the finally block (around line 4500) and change:

    } finally {
      this.rendering = false
      if (this._destroyPending) {
        this.finalizeDestroy()
      }
      this.resolveIdleIfNeeded()
    }

    to:

    } finally {
      this.rendering = false
      if (this._destroyPending) {
        this.finalizeDestroy()
      } else if (this.immediateRerenderRequested) {
        this.immediateRerenderRequested = false
        setImmediate(() => {
          if (!this._isDestroyed) this.requestRender()
        })
      }
      this.resolveIdleIfNeeded()
    }
  4. github-actions commented on Sep 19, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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