Skip to content

tui: keystroke right after a bracketed paste is inserted before the pasted text #53160

Open

Description

@Morris-Crypto-SSS

Summary

A keystroke that arrives in the same terminal input batch as a bracketed paste is inserted before the pasted text. With CJK IMEs this makes the punctuation pressed to commit a multi-character candidate land before the candidate (,你好 instead of 你好,). Single-character candidates are unaffected.

Environment

  • opencode version: 2.0.22 (Arch package opencode 2.0.22-1)
  • OS: Arch Linux, Linux 7.2.8-arch1-2 x86_64
  • Terminal: Alacritty 0.17.0 on niri 26.04 (Wayland), TERM=alacritty, COLORTERM=truecolor
  • Shell: /usr/bin/bash
  • Install/channel: release package, latest channel
  • Active plugins: none (no opencode.json/opencode.jsonc, no .opencode/plugin)

Reproduction

Real-world (fcitx5 5.1.23 + fcitx5-chinese-addons 5.1.15, Pinyin, Alacritty on Wayland):

  1. Open a session prompt with the Chinese IME active.
  2. Type nihao so 你好 is the first candidate.
  3. With the candidate list visible, press , (this commits 你好 and then ,).
  4. Prompt shows ,你好 instead of 你好,. A single-character candidate (ni → 你, then ,) is ordered correctly.

Deterministic, no IME required — verified against the real 2.0.22 binary:

Write \x1b[200~AAA\x1b[201~B to the TUI's pty in a single write:

  • Result: prompt renders BAAA.
  • Control (paste in one write, then B in a later write): prompt renders AAAB.
  • Control (AB in one write): prompt renders AB.
Minimal pty repro script (python3)
#!/usr/bin/env python3
"""Usage: python3 repro.py [same-chunk|separate-chunks]"""
import fcntl, os, pty, re, select, signal, struct, subprocess, sys, termios, time

mode = sys.argv[1] if len(sys.argv) > 1 else "same-chunk"
os.makedirs("/tmp/oc-repro", exist_ok=True)

master, slave = pty.openpty()
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0))
env = dict(os.environ, TERM="xterm-256color",
           XDG_DATA_HOME="/tmp/oc-repro-data", XDG_STATE_HOME="/tmp/oc-repro-state")
proc = subprocess.Popen(
    ["opencode", "--standalone", "--log-level", "error", "/tmp/oc-repro"],
    stdin=slave, stdout=slave, stderr=slave, env=env, cwd="/tmp/oc-repro",
    start_new_session=True)
os.close(slave)
out = bytearray()

def drain(seconds):
    end = time.time() + seconds
    while time.time() < end:
        if select.select([master], [], [], 0.1)[0]:
            try:
                chunk = os.read(master, 65536)
            except OSError:
                return
            if not chunk:
                return
            out.extend(chunk)

try:
    drain(12)  # wait for the TUI
    if mode == "same-chunk":
        os.write(master, b"\x1b[200~AAA\x1b[201~B")
    else:
        os.write(master, b"\x1b[200~AAA\x1b[201~")
        drain(0.8)  # let the paste settle
        os.write(master, b"B")
    drain(1.0)
    fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 101, 0, 0))
    drain(0.5)
    fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0))
    drain(1.5)
finally:
    proc.send_signal(signal.SIGKILL)
    proc.wait()
    os.close(master)

def screen_rows(data, rows=30, cols=110):
    grid = [[" "] * cols for _ in range(rows)]
    i, row, col = 0, 1, 1
    while i < len(data):
        b = data[i]
        if b == 0x1B:
            m = re.match(rb"\x1b\[([0-9;?]*)([A-Za-z])", data[i:])
            if m:
                ps = [int(p) for p in m.group(1).replace(b"?", b"").split(b";") if p.isdigit()]
                f = m.group(2)
                if f in (b"H", b"f"):
                    row = ps[0] if len(ps) > 0 and ps[0] else 1
                    col = ps[1] if len(ps) > 1 and ps[1] else 1
                elif f == b"G":
                    col = ps[0] if ps else 1
                elif f == b"A":
                    row = max(1, row - (ps[0] if ps else 1))
                elif f == b"B":
                    row += ps[0] if ps else 1
                elif f == b"C":
                    col += ps[0] if ps else 1
                elif f == b"D":
                    col = max(1, col - (ps[0] if ps else 1))
                elif f == b"K":
                    m0 = ps[0] if ps else 0
                    a, z = (col - 1, cols) if m0 == 0 else (0, col) if m0 == 1 else (0, cols)
                    for c in range(a, z):
                        grid[row - 1][c] = " "
                i += m.end()
                continue
            i += 1
        elif b in (0x0A, 0x0D, 0x08):
            if b == 0x0A: row += 1
            elif b == 0x0D: col = 1
            else: col = max(1, col - 1)
            i += 1
        elif 0x20 <= b < 0x7F:
            if 1 <= row <= rows and 1 <= col <= cols:
                grid[row - 1][col - 1] = chr(b)
            col += 1
            i += 1
        else:
            i += 1
    return ["".join(r).rstrip() for r in grid]

print(f"mode={mode}")
for n, line in enumerate(screen_rows(bytes(out)), 1):
    if "AAA" in line or "BAA" in line:
        print(f"  {n:2}| {line}")

Observed:

$ python3 repro.py same-chunk
mode=same-chunk
  17|                 BAAA

$ python3 repro.py separate-chunks
mode=separate-chunks
  17|                 AAAB

Expected Behavior

Input is applied in the order it was received: the pasted text first, then the following keystroke (AAAB, or 你好, with an IME).

Actual Behavior

The following keystroke is inserted first (BAAA / ,你好).

Additional Context

Root cause is the prompt's paste handling being asynchronous while key input is synchronous:

  • packages/tui/src/component/prompt/index.tsx onPaste (L1778–1801) calls event.preventDefault() and then void enqueuePaste((changed) => pasteInputText(normalizedText, changed)) (L1800).
  • enqueuePaste (L377–396) chains the work onto pasteQueue = pasteQueue.then(...), so insertion runs in a later microtask.
  • pasteInputText (L1441) awaits resolvePastedAttachments(...) (async filesystem probing) before input.insertText(normalizedText) (L1472).
  • Regular key input is inserted synchronously by the textarea, so any key dispatched in the same input batch is applied first.

Why this shows up with IMEs: terminals intentionally send multi-character IME commits as bracketed paste (alacritty/alacritty#7092), and Alacritty treats that as intentional (alacritty/alacritty#8934 was closed; alacritty/alacritty#8764 is the same class of failure in another TUI). Single-character commits are sent as raw text, which is why only multi-character candidates break.

Possible fix directions:

  • Insert plain-text bracketed pastes synchronously when they cannot resolve to a local attachment (e.g. a cheap synchronous existence check before choosing the async path); or
  • Process subsequent input events after the paste queue drains.

Workaround: commit the candidate with Space first, then type the punctuation separately (separate input batches, so the order is preserved).

Activity

  1. github-actions commented on Oct 4, 2026

    @github-actions
    Contributor

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

  2. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report and the repro script. I can confirm this on 2.0.22, and it still happens on the latest dev (c250c272).

    Sending a bracketed paste followed by a keystroke in a single terminal write (\x1b[200~AAA\x1b[201~B) leaves BAAA in the prompt. If the paste and the B arrive in separate writes, you get the correct AAAB. Your pty script gives the same result on both versions.

    The recording below shows the reproduction on 2.0.22, the screenshot shows the same result on dev, and the script below reproduces it. Your analysis matches what I see in the code: the prompt's paste handler inserts the text asynchronously, through enqueuePaste/pasteInputText, while ordinary keys are inserted right away. A key in the same input batch therefore lands first.

    Until this is fixed, your workaround still works: commit the candidate with Space, then type the punctuation separately.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    import { Effect } from "effect"
    import { OpenCodeDriver } from "opencode-drive"
    
    // Sends a bracketed paste ("AAA") immediately followed by a keystroke ("B") as one input batch.
    // Expected prompt: "AAAB". Bug: "BAAA".
    export default OpenCodeDriver.use(
      { ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}), tui: { recording: true } },
      ({ ui }) =>
        Effect.gen(function* () {
          yield* ui.screenshot("before")
          yield* ui.type("\x1b[200~AAA\x1b[201~B")
          yield* Effect.sleep("1500 millis")
          yield* ui.screenshot("after")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions