Skip to content

Bug: JetBrains terminal sends free mouse motion without the motion bit in SGR mode, causing mousedown events on every mouse movement #931

@gfreitash

Description

@gfreitash

Summary

When running inside a JetBrains IDE terminal (JediTerm), every free mouse movement (no button held) is decoded by MouseParser.decodeSgrEvent as a mousedown event instead of a move event. This causes interactive elements to fire their onMouseDown handlers continuously as the mouse moves, breaking scrollbars, collapsibles, and even the chat.

Related opencode issues:

Root cause

When 1003 (any-event) + 1006 (SGR) mouse tracking is enabled, a correct terminal sends free motion events with Cb=35:

ESC [ < 35 ; col ; row M    (35 = 32 | 3 — motion bit set, no button held)

JetBrains JediTerm sends Cb=3 instead:

ESC [ < 3 ; col ; row M     (3 = no button — motion bit absent)

In decodeSgrEvent, the motion bit is checked as:

const isMotion = (rawButtonCode & 32) !== 0;

With rawButtonCode=3, isMotion is false. The event falls through to:

type = pressRelease === "M" ? "down" : "up";

Since JetBrains uses the M (press) terminator, every free motion becomes type="down" — a mousedown event. There is never a corresponding mouseup, because JetBrains does not send a release (m terminator) for motion events.

This is a JetBrains bug: the motion bit (32) is defined in the xterm spec for button-motion events and applies equally to SGR mode. JetBrains correctly includes the motion bit for drag events (Cb=32 when left button is held), but omits it for free motion.
A bug report has been filed with JetBrains at https://youtrack.jetbrains.com/issue/IJPL-242176/Terminal-SGR-mouse-tracking-mode-1003-missing-motion-bit-32-for-hovering

Impact

Because every mouse movement becomes a mousedown with button=0:

  • Scrollbars jump to the mouse position on every movement — SliderRenderable.onMouseDown sets isDragging=true and calls updateValueFromMouseDirect immediately.
  • Collapsible elements toggle on every movement — any onMouseDown handler fires continuously.
  • Chat moves the cursor to mouse location immediately
  • Any other location that interacts with mousedown

Options

A. Wait for JetBrains to fix the bug

The correct long-term fix is on the JetBrains side. Once they send Cb=35 for free motion, the issue disappears with no changes needed here.

B. Add a workaround in decodeSgrEvent

private decodeSgrEvent(rawButtonCode: number, wireX: number, wireY: number, pressRelease: "M" | "m"): RawMouseEvent {
const button = rawButtonCode & 3
const isScroll = (rawButtonCode & 64) !== 0
const scrollDirection = !isScroll ? undefined : MouseParser.SCROLL_DIRECTIONS[button]
const isMotion = (rawButtonCode & 32) !== 0
const modifiers = {
shift: (rawButtonCode & 4) !== 0,
alt: (rawButtonCode & 8) !== 0,
ctrl: (rawButtonCode & 16) !== 0,
}
let type: MouseEventType
let scrollInfo: ScrollInfo | undefined
if (isMotion) {
const isDragging = this.mouseButtonsPressed.size > 0
if (button === 3) {
type = "move"
} else if (isDragging) {
type = "drag"
} else {
type = "move"
}
} else if (isScroll && pressRelease === "M") {
type = "scroll"
scrollInfo = {
direction: scrollDirection!,
delta: 1,
}
} else {
type = pressRelease === "M" ? "down" : "up"
if (type === "down" && button !== 3) {
this.mouseButtonsPressed.add(button)
} else if (type === "up") {
this.mouseButtonsPressed.clear()
}
}
return {
type,
button: button === 3 ? 0 : button,
x: wireX - 1,
y: wireY - 1,
modifiers,
scroll: scrollInfo,
}
}

In opentui/packages/core/src/lib/parse.mouse.ts:156, the condition detecting motion events can be extended to also catch the JetBrains-specific broken encoding. button === 3 && pressRelease === "M" has no valid press semantics in SGR mode (button=3 means "no button"; there is nothing to press), so treating it as motion is safe and unambiguous:

const isJetBrainsMissingMotionBit =
  process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
  && button === 3
  && pressRelease === "M";

if (isMotion || isJetBrainsMissingMotionBit) {
  // ...
}

Scoping the check to TERMINAL_EMULATOR === "JetBrains-JediTerm" ensures:

  • No other terminal is affected.
  • Once JetBrains fixes the bug and starts sending Cb=35, their terminal will take the isMotion=true path and isJetBrainsMissingMotionBit becomes unreachable dead code — no downstream breakage.

Reproduction

The following script enables 1003 + 1006 mouse tracking and prints every raw escape sequence received. Run it inside a JetBrains terminal and move the mouse without clicking — you will see a stream of ESC[<3;...]M events. Run it in Ghostty or another correct terminal and you will see ESC[<35;...]M instead.

#!/usr/bin/env python3
"""
Print raw terminal escape sequences for mouse events.

Enables SGR + any-event mouse tracking,
then dumps every byte received so you can verify how your terminal
encodes mouse motion vs button presses.

When moving the mouse (no buttons held), a correct terminal sends:
  ESC [ < 35 ; col ; row M   (raw button code 35 = 32|3, motion bit set)

With the IntelliJ bug you will see instead:
  ESC [ < 3 ; col ; row M    (raw button code 3 = no button, motion bit absent)

Press Ctrl+C to quit.
"""

import os
import signal
import sys
import termios
import tty


ENABLE_ANY_EVENT = "\x1b[?1003h"
ENABLE_SGR = "\x1b[?1006h"
DISABLE_ANY_EVENT = "\x1b[?1003l"
DISABLE_SGR = "\x1b[?1006l"


def main() -> None:
    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)

    def restore(sig=None, frame=None) -> None:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        sys.stdout.write(DISABLE_ANY_EVENT + DISABLE_SGR + "\r\n")
        sys.stdout.flush()
        os._exit(0)

    signal.signal(signal.SIGINT, restore)

    tty.setraw(fd)
    sys.stdout.write(ENABLE_ANY_EVENT + ENABLE_SGR)
    sys.stdout.write("Move mouse, scroll, click. Press Ctrl+C to quit.\r\n")
    sys.stdout.flush()

    while True:
        data = os.read(fd, 64)
        if b"\x03" in data:
            restore()
        hex_bytes = " ".join(f"{b:02x}" for b in data)
        readable = data.decode("latin1").replace("\x1b", "ESC").replace("\r", "CR")
        sys.stdout.write(f"bytes={hex_bytes}  text={readable!r}\r\n")
        sys.stdout.flush()


if __name__ == "__main__":
    main()

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