Skip to content

tui: escape sequences split across reads are typed into the prompt (mouse reports, PageUp) #60268

Open

Description

@Siarhei-Yarkavy

Summary

When a terminal escape sequence arrives at the TUI in two fragments (the leading ESC in one read, the rest in the next), the leading ESC is treated as a standalone Escape key press and the remainder is inserted into the prompt as literal text. This shows up as garbage in the input box such as [5~ (PageUp), [<51;103;19M (mouse motion with Ctrl) or :ffff/ffff/ffff:eded/ecec/eeee (an OSC 10/11 colour reply).

Environment

  • opencode version: 2.2.1 (also reproduced on 2.2.0)
  • OS: Linux 7.0.0-38-generic x86_64 (server); client is Windows 11 10.0.26200.9711
  • Terminal: Windows Terminal 1.25.2733.0 → pwsh 7.6.8 → ssh (OpenSSH_10.5p1) → Linux. TERM=xterm-256color, COLORTERM empty on the client, TERM_PROGRAM unset. Also reproduced inside tmux 3.6 (TERM=tmux-256color).
  • Shell: /bin/bash
  • Install/channel: latest
  • Active plugins: none

Transport detail: the client terminal is Windows Terminal, which runs the shell through ConPTY. The same setup does not reproduce in mintty (Git Bash), which does not use ConPTY.

Reproduction

Deterministic reproduction with TUI raw input (no terminal client needed — I drove opencode through a pty):

  1. Start opencode attached to a pty.
  2. Wait for the UI to settle (~5 s).
  3. Write ESC (0x1b) to stdin.
  4. Wait ~60 ms.
  5. Write [5~ (the rest of PageUp).

Result: [5~ is rendered into the prompt as literal text.

If ESC[5~ is written in a single write, it is consumed correctly and nothing appears.

The same holds for other sequences: splitting ESC from [<35;58;10M or from ]11;rgb:ffff/ffff/ffff produces the corresponding tail as text. With the PageUp case, a 10 ms gap was still tolerated and a 60 ms gap leaked every time.

Real-world reproduction:

  1. Use opencode over Windows Terminal → ssh → Linux (ConPTY transport).
  2. Enable mouse (default) and move the mouse, or press PageUp / use the wheel.
  3. Intermittently, fragments like [5~, <35;58;10M or :ffff/ffff/ffff:eded/ecec/eeee appear in the prompt input.

Expected Behavior

Incomplete escape sequences should be buffered until they are complete, or until a short timeout expires, instead of the leading ESC being committed as an Escape key press and the remainder being treated as typed text.

Actual Behavior

The remainder of the sequence is inserted into the prompt as literal characters, e.g.:

[6~5~5~5~[5~6~5~[5~5~~[6[13;5u
<51;103;19M
:ffff/ffff/ffff:eded/ecec/eeee

Additional Context

Captured raw stdin with OTUI_STDIN_LOG=/tmp/oc-stdin.log in a session where the garbage appeared: one capture held 67 SGR mouse reports, all of them with the ESC byte intact, e.g. \x1b[<51;102;19M, yet the UI still printed <51;103;19M as text. So at least some cases are not caused by fragmentation upstream; the parser drops the ESC and keeps the tail. Verified with od -c that no byte was lost between tmux and opencode's stdin.

Notes on the leaked mouse reports: the ones observed leaking had cb = 51, i.e. 32 (motion) + 16 (Ctrl) + 3 (no button). Plain motion (cb = 35) was not observed leaking. Injecting a single intact \x1b[<51;102;19M into a fresh TUI does not reproduce it, so the mouse case is intermittent (observed while a response was streaming/rendering).

Workarounds that help:

  • "mouse": false in cli.json — removes the mouse source entirely (wheel becomes PageUp/PageDown).
  • Running inside tmux, which reassembles fragments for most sequences.
  • "animations": false — unrelated to the leak, but makes the TUI less busy.

Not related: the terminal emulator version (Windows Terminal 1.25 stable and 1.26 Preview behave the same), the ssh client (both Windows OpenSSH 9.5p2 and Git's 10.5p1 reproduce).

Activity

  1. opencodejr commented on Oct 29, 2026

    @opencodejr
    Contributor

    Thanks for the very precise report. I could reproduce this on 2.2.1, and it still happens on the latest dev (87a35fa) run from source.

    Steps: run the TUI under a PTY (TERM=xterm-256color), wait for it to settle, write a lone ESC, wait 60 ms, then write the rest of the sequence.

    • [5~ (PageUp): [5~ shows up in the prompt.
    • ]11;rgb:ffff/ffff/ffff + BEL: ]11;rgb:ffff/ffff/ffff shows up in the prompt.
    • The same sequences in a single write, or with a 10 ms gap: nothing leaks.
    • A split SGR mouse report (ESC + [<35;58;10M): no leak in my runs.
    • I did not reproduce the intermittent case of an intact ESC[<51;…M leaking while a response streams.

    The script below reproduces it (python3 repro.py 60 pageup). There's no video, because our TUI automation tool can't send raw, split terminal bytes.

    It looks like the bundled OpenTUI stdin parser treats a lone ESC as the Escape key after about 20 ms. After that it can only recover split mouse reports; other CSI tails ([5~) and OSC tails (]…) are typed as text. This overlaps with #57345 (split OSC 10/11 replies), but it also affects normal keys such as PageUp. #57517 (OSC 4 replies) is related too.

    Reproduction script (repro.py)
    #!/usr/bin/env python3
    """Reproduce anomalyco/opencode#60268: an escape sequence split after the leading ESC
    is typed into the TUI prompt.
    
    Usage: python3 repro.py GAP_MS SEQ [-- command...]   (default command: opencode)
      SEQ: pageup | mouse | osc11
    Starts the TUI under a PTY, waits for it to settle, writes ESC, waits GAP_MS, writes
    the rest of the sequence, and reports whether the tail was rendered on screen.
    """
    import os, pty, re, select, signal, struct, sys, time, fcntl, termios, tempfile
    
    args = sys.argv[1:]
    cmd = args[args.index("--") + 1:] if "--" in args else ["opencode", "--standalone"]
    args = args[:args.index("--")] if "--" in args else args
    gap = float(args[0]) / 1000 if args else 0.06
    seq = args[1] if len(args) > 1 else "pageup"
    tails = {
        "pageup": (b"[5~", "[5~"),
        "mouse": (b"[<35;58;10M", "<35;58;10M"),
        "osc11": (b"]11;rgb:ffff/ffff/ffff\x07", "ffff/ffff/ffff"),
    }
    tail, needle = tails[seq]
    
    proj = tempfile.mkdtemp(prefix="oc60268-")
    home = tempfile.mkdtemp(prefix="oc60268-home-")
    pid, fd = pty.fork()
    if pid == 0:
        os.chdir(proj)
        env = dict(os.environ, TERM="xterm-256color", HOME=home)
        os.execvpe(cmd[0], cmd, env)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
    os.set_blocking(fd, False)
    out = bytearray()
    
    def pump(seconds):
        end = time.time() + seconds
        while time.time() < end:
            r, _, _ = select.select([fd], [], [], 0.01)
            if r:
                try:
                    out.extend(os.read(fd, 65536))
                except OSError:
                    return
    
    pump(float(os.environ.get("SETTLE", "15")))  # let the UI settle
    mark = len(out)
    os.write(fd, b"\x1b")        # leading ESC alone
    pump(gap)
    os.write(fd, tail)           # rest of the sequence
    pump(2)
    os.kill(pid, signal.SIGTERM)
    after = bytes(out[mark:]).decode("utf-8", "replace")
    plain = re.sub(r"\x1b\[[0-9;?<>=]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)|\x1b.", "", after)
    leaked = needle in plain
    print(f"seq={seq} gap={gap*1000:.0f}ms leaked={leaked}")
    if leaked:
        i = plain.index(needle)
        print("context:", repr(plain[max(0, i - 40):i + 40]))

    Run it: python3 repro.py

  2. Siarhei-Yarkavy commented on Oct 30, 2026

    @Siarhei-Yarkavy
    Author

    Some measurements on where the split actually gets absorbed — might help narrow this down to the parser rather than the transport.

    Setup: opencode 2.2.3, tmux 3.8 (static build), escape-time 100, terminal client attached through a pty. Raw bytes were written to the tmux client, i.e. exactly the path a real terminal/ConPTY split would take.

    Method: write ESC, wait gap, then write the rest of the sequence; capture the pane afterwards and look for the tail rendered as text.

    Sequence 150 ms gap 400 ms gap
    SGR wheel [<65;88;19M not leaked not leaked
    PageUp [5~ not leaked not leaked
    OSC 11 ]11;rgb:ffff/ffff/ffff BEL not leaked not leaked
    Up arrow [A not leaked not leaked

    So tmux does reassemble client-side splits (at least up to 400 ms here), even with escape-time 100. With tmux in the path, the transport-level split does not reach OpenTUI.

    The same inputs written directly to the pane pty (bypassing tmux, as in the repro in the issue) still leak: ESC + 60 ms + [5~ puts [5~ in the prompt every time. That is consistent with the parser committing a lone ESC after ~20 ms.

    Also tried the experimental parser — no change:

    • OTUI_NATIVE_INPUT_PARSER=1 / =true: [5~ still leaks.
    • experimental_nativeInput: true in cli.json: [5~ still leaks.

    So there seem to be two layers:

    1. Transport splits (ConPTY/ssh): tmux absorbs these; without tmux they hit the parser directly.
    2. Parser-side: a committed lone ESC (timeout) and/or dropping ESC from an otherwise intact sequence while the TUI is busy — the cb=51 case noted in the report. tmux cannot help there.

    Observed in the wild inside tmux 3.8 as well: wheel reports showing up as ;88;19M<65;88;19M, plus stray A (tail of ESC[A) and [5~ in the composer. Rare, but it does happen with tmux in the path.

  3. Siarhei-Yarkavy commented on Oct 30, 2026

    @Siarhei-Yarkavy
    Author

    One more concrete case, because it explains the stray A / B characters people see in the session-search input.

    Those are not random letters — they are the final byte of the Up/Down arrow sequences:

    Up   ESC [ A   (or ESC O A in application-cursor mode)
    Down ESC [ B   (or ESC O B)
    

    If the sequence is split after the 2-byte prefix (ESC[ or ESC O), the parser consumes the partial CSI/SS3, times out, and the remaining byte is typed as text.

    Deterministic repro (raw bytes written straight to the pane pty, 60 ms gap):

    bytes written rendered in prompt
    ESC + [A [A
    ESC[ + A A
    ESC O + A A
    ESC[ + B B
    ESC O + B B

    This is why the session picker shows single A/B characters: arrows navigate the list while the search field keeps focus, so the leaked final byte lands in the query.

    Same root cause as the [5~ and ;88;19M cases, just a different split point.

  4. TOMUIV commented on Nov 4, 2026

    @TOMUIV

    Confirming on a Windows server (the report above is Linux server + Windows client), and adding two
    things that I think point at a much cheaper fix than the parser rework.

    1. Still present on every channel

    latest 2.2.5, 0.0.0-dev-578, 0.0.0-beta-19507 — all three still build the stdin parser with
    timeoutMs: 20:

    ... new _W({timeoutMs:20,max ...
    

    So this is not fixed anywhere as of today.

    2. What it looks like here

    Crop from a live Windows-server session, pointer only moved over the window, nothing clicked:

    " ;17;2M / M5;20;4M / 6M;10M / 12M typed into the prompt"

    Those are tails of SGR mouse reports with the ESC[< head missing, plus stray M terminators from the
    previous report. Earlier in the same session we also collected 5;35;25M, i.e. a report whose cb = 35
    (plain motion, no button) — so the cb = 35 case is worth keeping in scope next to cb = 51, even though
    a single split ESC + [<35;...M did not reproduce in the maintainer's PTY run. On this transport the
    mouse reports leak continuously, not intermittently.

    3. The three-state mouse mode already exists natively — it just is not wired up

    The native terminal session accepts mouse and mouse-motion as separate flags, and validates
    exactly three modes:

    setupTerminal({ ..., mouse: this._useMouse, mouseMovement: this.enableMouseMovement, ... })
    
    case "mouse": {
      let l = s.mode;
      if (l !== "disabled" && l !== "drag" && l !== "motion")
        throw TypeError("Session mouse mode must be disabled, drag, or motion");
      n[...] = l === "disabled" ? 0 : l === "drag" ? 1 : 2;
    }
    

    But nothing in the app ever sets enableMouseMovement:

    this.enableMouseMovement = r.enableMouseMovement ?? !0     // always "motion"
    
    enableMouse(){ ... this.nativeSession.control({kind:"mouse",
        mode: this.enableMouseMovement ? "motion" : "drag"}); ... }
    

    and the config schema only allows a boolean:

    mouse: z.boolean().describe("Enable terminal mouse capture")
    ...
    mouse: A.mouse ?? !0
    

    Net effect: startup always emits all four modes and therefore enables all-motion reporting —

    ESC[?1000h ESC[?1002h ESC[?1003h ESC[?1006h
    

    ?1003h is what turns every pointer movement into an ESC[<b;x;yM write, i.e. it is the thing that
    supplies this bug with a constant stream of sequences. Without it there is nothing to split.

    So #60942/#60943 could land as plumbing rather than new machinery: accept
    mouse: "disabled" | "drag" | "motion" (the native side already understands those three literals)
    and pass mouseMovement: mode === "motion". drag sends ?1002h only: click, drag, wheel and keys all
    keep working, hover highlighting goes away, and the motion stream — the bulk of what leaks — disappears.
    That does not fix the parser, but it removes the practical impact for mouse-forced users without turning
    mouse off entirely, which is the only option the boolean gives us today.

    4. On the parser side

    Whatever the timeout is, the tail of an uncommitted sequence should never reach the editor. A truncated
    CSI/OSC is never a legitimate keystroke, so dropping the partial sequence (or buffering until the
    final byte 0x40-0x7E) is safe, while emitting its tail is guaranteed garbage. This also covers the
    cb = 51 case from the report above where the ESC itself was present in stdin.

    Current workaround

    A stdin relay between the pty and the TUI that re-assembles control sequences and drops reports with
    (b & 32) && (b & 3) == 3 before the app reads them. Clicks, drag, wheel and keys pass through
    untouched; the prompt stays clean. If a short-term mitigation is wanted, that is ~150 lines and I am
    happy to upstream it as a patch — but the two changes above are the real fix.

  5. zxyqq commented on Nov 11, 2026

    @zxyqq

    Control: the leak only reproduces without tmux (direct ConPTY → SSH PTY)

    Environment:

    • opencode 2.2.7 (TUI + background service)
    • Linux host over SSH from Windows 11 / Windows Terminal (ConPTY)
    • No tmux on the remote side for the failing cases
    • With tmux 3.2a between SSH and opencode, none of the cases below reproduce

    Repro A — fresh start (no tmux):
    Running opencode directly in the SSH shell shows :eeee/eeee/eeee in the prompt as soon as the TUI draws.

    Repro B — resume after suspend (no tmux):

    1. Run opencode directly in the SSH shell.
    2. Press Ctrl+Z (SIGTSTP), then fg (SIGCONT).
    3. On the redraw, :eeee/eeee/eeee is typed into the prompt again.

    Repro C — resuming a specific session (no tmux):
    opencode -s <session-id> renders :ffff/ffff/ffff:eeee/eeee/eeee in the prompt.

    Control:
    Running the same commands inside tmux 3.2a on the same host/terminal does not leak. So the split only happens on the direct ConPTY → SSH pty path; tmux sitting in between appears to normalize/absorb the replies.

    In all failing cases the leading part is consumed: the visible text starts at : / eeee, i.e. the replies to the ESC ]10;? (foreground) and ESC ]11;? (background) queries are split after the leading ESC, the parser swallows the prefix, and the remainder is typed as text. ffff/ffff/ffff is the terminal foreground (white) and eeee/eeee/eeee the background (light gray), so this is the OSC 10 + OSC 11 reply pair concatenated.

    This matches the lone-ESC / 20 ms timeout described above. SIGCONT/redraw is worth noting as an additional trigger: the TUI re-issues the color queries when it resumes, so every fg can produce a fresh leak when running outside tmux.

  6. Siarhei-Yarkavy commented on Nov 13, 2026

    @Siarhei-Yarkavy
    Author

    In Tmux it is also reproducible but not always. I didn't press A, I pressed up/down/up/down... keys in random order and sometimes it prints codes A and B.

    изображение
  7. Siarhei-Yarkavy commented on Nov 13, 2026

    @Siarhei-Yarkavy
    Author

    again some mouse code when I use opencode inside tmux
    изображение

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions