Skip to content

bug: mouse escape sequences garbled after renderer destroy — cleanupBeforeDestroy() disables raw mode before mouse tracking #904

@agutmanstein-scale

Description

@agutmanstein-scale

Description

When a renderer is destroyed (via destroy()finalizeDestroy()cleanupBeforeDestroy()), mouse escape sequences leak into the terminal as garbled text. This happens because cleanupBeforeDestroy() calls stdin.setRawMode(false) before mouse tracking is disabled.

Root Cause

In packages/core/src/renderer.ts, cleanupBeforeDestroy() (line ~2210-2213):

this.stdin.removeListener("data", this.stdinListener)
if (this.stdin.setRawMode) {
  this.stdin.setRawMode(false)  // ← ECHO re-enabled! Mouse still active!
}

Later in finalizeDestroy(), this.lib.destroyRenderer(this.rendererPtr) sends mouse-disable sequences — but by then, setRawMode(false) has already re-enabled terminal ECHO. Any mouse events arriving between these two calls get echoed as raw bytes, appearing as garbled text like:

35;89;19M35;84;20M35;76;22M35;71;23M35;67;23M35;67;24M35...

The more complex the UI (i.e., the longer root.destroyRecursively() takes), the wider this garbling window becomes.

The Correct Pattern Already Exists

suspend() (line ~2052-2077) does this correctly:

this.disableMouse()              // 1. mouse off (raw mode still on, no ECHO)
// ... cleanup ...
this.lib.suspendRenderer(...)    // 2. native suspend
this.stdin.setRawMode(false)     // 3. raw mode off LAST (safe, mouse already disabled)

Proposed Fix

Add disableMouse() + stdin drain to cleanupBeforeDestroy(), matching the suspend() pattern:

// Disable mouse tracking before disabling raw mode to prevent
// mouse events from being echoed as garbled text
if (this._useMouse) {
  this.disableMouse()
}

this.stdin.removeListener("data", this.stdinListener)
while (this.stdin.read() !== null) {} // drain buffered mouse events

if (this.stdin.setRawMode) {
  this.stdin.setRawMode(false)
}

This ensures:

  1. Mouse disable sequences (\x1b[?1000l, \x1b[?1003l, \x1b[?1006l) are sent while raw mode is still on (no ECHO)
  2. Stdin is drained of buffered mouse events before ECHO is re-enabled
  3. Then raw mode is safely disabled

Safety: disableMouse() accesses stdinParser and rendererPtr which are not yet destroyed at this point in the cleanup. The _useMouse guard skips the call if mouse was never enabled. The stdin drain pattern matches resume().

Reproduction

Observed in opencode (which uses @opentui/core@0.1.95):

  1. Run a TUI application with mouse tracking enabled
  2. Move the mouse while the TUI is active
  3. Exit the TUI (Ctrl+C, quit command, or any path that triggers destroy())
  4. Move the mouse in the shell immediately after exit
  5. Garbled escape sequences appear in the terminal

Affects both @opentui/core@0.1.90 and @opentui/core@0.1.95.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions