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:
- Mouse disable sequences (
\x1b[?1000l, \x1b[?1003l, \x1b[?1006l) are sent while raw mode is still on (no ECHO)
- Stdin is drained of buffered mouse events before ECHO is re-enabled
- 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):
- Run a TUI application with mouse tracking enabled
- Move the mouse while the TUI is active
- Exit the TUI (Ctrl+C, quit command, or any path that triggers
destroy())
- Move the mouse in the shell immediately after exit
- Garbled escape sequences appear in the terminal
Affects both @opentui/core@0.1.90 and @opentui/core@0.1.95.
Related
Description
When a renderer is destroyed (via
destroy()→finalizeDestroy()→cleanupBeforeDestroy()), mouse escape sequences leak into the terminal as garbled text. This happens becausecleanupBeforeDestroy()callsstdin.setRawMode(false)before mouse tracking is disabled.Root Cause
In
packages/core/src/renderer.ts,cleanupBeforeDestroy()(line ~2210-2213):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: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:Proposed Fix
Add
disableMouse()+ stdin drain tocleanupBeforeDestroy(), matching thesuspend()pattern:This ensures:
\x1b[?1000l,\x1b[?1003l,\x1b[?1006l) are sent while raw mode is still on (no ECHO)Safety:
disableMouse()accessesstdinParserandrendererPtrwhich are not yet destroyed at this point in the cleanup. The_useMouseguard skips the call if mouse was never enabled. The stdin drain pattern matchesresume().Reproduction
Observed in opencode (which uses
@opentui/core@0.1.95):destroy())Affects both
@opentui/core@0.1.90and@opentui/core@0.1.95.Related