Skip to content

tui: OSC 4 palette replies leak into the prompt input as text #57517

Open

Description

@ginocampra

Summary

Terminal replies to the OSC 4 palette query (ESC ] 4 ; N ; rgb:rrrr/gggg/bbbb ST) leak their tail into the prompt input as if the user had typed it. With theme.name = "system" the full palette is queried on startup, so :cccc/cccc/cccc (the ANSI color #7 / cursor color) is pre-filled into the prompt on every launch and gets submitted as a user message.

Environment

  • opencode version: 2.1.1
  • OS: Linux 7.0.0-38-generic (Ubuntu 24.04 kernel), x86_64
  • Terminal: TERM=xterm-256color over SSH; the client terminal reports the Windows Console "Campbell" palette (fg #cccccc, bg #0c0c0c) and answers OSC 10/11/12/13..17/19 and OSC 4;0..15 with ST-terminated rgb: replies
  • Shell: /usr/bin/zsh
  • Install/channel: latest
  • Active plugins: [email protected]

Reproduction

  1. Set ~/.config/opencode/cli.json to use the terminal-derived theme:
    { "theme": { "name": "system" } }
  2. Connect from a terminal that answers OSC color queries (ESC ]10;?, ESC ]11;?, ESC ]4;N;?) with ST (ESC \) terminated replies, e.g. ESC ]4;7;rgb:cccc/cccc/cccc ESC \.
  3. Run opencode (also reproducible with opencode --standalone).
  4. The prompt input is already filled with :cccc/cccc/cccc:cccc/cccc/cccc.

Instrumented capture of the same run:

script -I /tmp/in.log -O /tmp/out.log -q -c opencode

Expected Behavior

The prompt input is empty on startup; terminal color replies are consumed by the terminal-capability parser only.

Actual Behavior

The tail of the palette reply is inserted into the prompt as text. If the user presses Enter, it is sent as a user message. In this report the leaked string was:

:cccc/cccc/cccc:cccc/cccc/cccc

Additional Context

  • /tmp/in.log (stdin) contains no typed bytes; only valid replies such as
    \x1b]10;rgb:cccc/cccc/cccc\x1b\\, \x1b]11;rgb:0c0c/0c0c/0c0c\x1b\\, \x1b]4;7;rgb:cccc/cccc/cccc\x1b\\.
  • /tmp/out.log (stdout) shows the TUI rendering :cccc/cccc/cccc inside the input area.
  • Replaying the captured stdin into a pty reproduces the leak.
  • Bisecting the captured stdin by sequence type: injecting only the OSC 4;N palette replies reproduces the leak (3 occurrences), while injecting only OSC 10/11/12/17/19 color replies does not.
  • The parser appears to consume ESC ]4;7;rgb and drop the terminator, leaving :cccc/cccc/cccc behind.
  • Query volume depends on the theme: with theme.name = "system" the run issued 48 OSC 4;N queries (3 full sweeps); with a built-in theme and mode: "dark" only ~2.
  • Workaround: running inside tmux avoids the issue (tmux answers the color queries itself and does not forward the outer terminal replies).
  • Not reproducible in a pty that answers each query exactly once; it appears when the terminal sends additional/repeated palette replies.

Activity

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and the captures. I was able to reproduce this on 2.1.1, and it still happens on the latest dev (d898c04).

    I ran opencode in a PTY with {"theme":{"name":"system"}} and a script acting as the terminal, answering the OSC 10/11/12–19 and OSC 4;N;? queries with ST-terminated rgb: replies:

    • When each palette reply arrives whole in one write, nothing leaks. Sending every reply twice, or sending an extra full set later, didn't leak either.
    • When a palette reply arrives in two pieces (ESC]4;N;rgb first, then :rrrr/gggg/bbbb ESC\), the tail ends up in the prompt. This happens even when the second piece follows only 20 ms later. With a full palette sweep the prompt fills with :0000/0000/0000:1010/1010/1010…:cccc/cccc/cccc….

    So the trigger looks like a palette reply arriving split across reads, which is likely over SSH, rather than duplicate replies. It is closely related to #57345, which shows the same leak for a delayed OSC 11 reply. Here OSC 4 palette replies leak even with a very short gap.

    The script below reproduces it (python3 repro.py gap0.02 leaks, python3 repro.py once is the control). There's no video because our TUI automation can't inject raw terminal reply bytes. As you found, running inside tmux avoids it for now.

    Reproduction script (repro.py)
    #!/usr/bin/env python3
    """Kill any running `opencode serve --service` between runs.
    Reproduce anomalyco/opencode#57517: OSC 4 palette replies leak into the TUI prompt.
    Usage: python3 repro.py MODE [-- command...]   (default command: opencode)
    MODE: gap<seconds> - split each palette reply after "ESC]4;N;rgb" and send the rest <seconds> later (e.g. gap0.02)
          once   - answer every OSC 4;N;? query exactly once, whole
          double - answer every OSC 4;N;? query twice (terminal sends repeated replies)
          split  - answer once, but split the reply after 'ESC]4;N;rgb' with a 2s gap
    Uses an isolated XDG config with {"theme":{"name":"system"}}."""
    import os, pty, re, select, signal, struct, sys, time, fcntl, termios, tempfile, json
    mode = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != "--" else "double"
    cmd = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else ["opencode", "--standalone"]
    root = tempfile.mkdtemp(prefix="oc57517-")
    proj = os.path.join(root, "proj"); os.makedirs(proj)
    cfg = os.path.join(root, "config", "opencode"); os.makedirs(cfg)
    json.dump({"theme": {"name": "system"}}, open(os.path.join(cfg, "cli.json"), "w"))
    pid, fd = pty.fork()
    if pid == 0:
        os.chdir(proj)
        env = dict(os.environ, TERM="xterm-256color", XDG_CONFIG_HOME=os.path.join(root, "config"),
                   XDG_DATA_HOME=os.path.join(root, "data"), XDG_STATE_HOME=os.path.join(root, "state"),
                   XDG_CACHE_HOME=os.path.join(root, "cache"))
        os.execvpe(cmd[0], cmd, env)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
    os.set_blocking(fd, False)
    ST = b"\x1b\\"
    def color(n):  # Campbell-ish palette
        return b"cccc/cccc/cccc" if n == 7 else b"%02x%02x/%02x%02x/%02x%02x" % ((n*16,)*6)
    out = bytearray(); scanned = 0; pending = []; queries = 0
    start = time.time()
    while time.time() < start + 30:
        r, _, _ = select.select([fd], [], [], 0.02)
        if r:
            try: out += os.read(fd, 65536)
            except OSError: break
        allq = list(re.finditer(rb"\x1b\](\d+)(?:;(\d+))?;\?", bytes(out)))
        for m in allq[scanned:]:
            code = int(m.group(1)); resp = None
            if code == 4:
                n = int(m.group(2)); queries += 1
                full = b"\x1b]4;%d;rgb:" % n + color(n) + ST
                if mode == "split":
                    pending.append((time.time(), b"\x1b]4;%d;rgb" % n))
                    pending.append((time.time()+2, b":" + color(n) + ST))
                else:
                    pending.append((time.time(), full))
                    if mode == "double": pending.append((time.time()+0.3, full))
                if mode == "late":
                    pending.pop(); pending.append((time.time()+3, full))
                if mode == "unsolicited":
                    pending.append((start+12+n*0.01, full))
                if mode.startswith("gap"):
                    pending.pop(); g=float(mode[3:])
                    pending.append((time.time(), b"\x1b]4;%d;rgb" % n))
                    pending.append((time.time()+g, b":" + color(n) + ST))
            elif code in (10, 12, 13, 14, 15, 16, 17, 19): resp = b"\x1b]%d;rgb:cccc/cccc/cccc" % code + ST
            elif code == 11: resp = b"\x1b]11;rgb:0c0c/0c0c/0c0c" + ST
            if resp: pending.append((time.time(), resp))
        scanned = len(allq)
        now = time.time()
        for item in [p for p in pending if p[0] <= now]:
            os.write(fd, item[1]); pending.remove(item)
    os.kill(pid, signal.SIGTERM)
    open(f"/workspace/repro/out-{mode}.bin", "wb").write(out)
    text = re.sub(rb"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\\\)", b"", bytes(out))
    leaks = sorted(set(re.findall(rb":[0-9a-f]{4}/[0-9a-f]{4}/[0-9a-f]{4}", text)))
    print(f"mode={mode} osc4_queries={queries} leaked={bool(leaks)} {[l.decode() for l in leaks]}")

    Run it: python3 repro.py

  2. dqbpqdpb commented on Oct 23, 2026

    @dqbpqdpb
     Same here on opencode v2.1.4 (latest).
    
     Environment: SSH from Windows Terminal (Windows console "Campbell" palette) into Debian;
     remote TERM=xterm-256color, TERM_PROGRAM/COLORTERM unset. Theme is the built-in "opencode"
     one, not "system".
    
     Every launch the prompt is prefilled with `:0c0c/0c0c/0c0c` — the tail of the terminal's
     `ESC]11;rgb:0c0c/0c0c/0c0c ST` reply (Campbell background, also palette[0]).
    
     Local PTY repro on 2.1.4:
     - whole reply in a single write -> no leak
     - `ESC]11;rgb`, then `:0c0c/0c0c/0c0c` 2s later -> leaks into the prompt, exactly as reported
    
     Also worth noting: a built-in theme still triggers `ESC]4;0;?` at startup, and pinning
     `theme.mode: "dark"` neither stops the OSC 10/11 probes nor the leak.
    
  3. Anthony14FR commented on Nov 12, 2026

    @Anthony14FR

    I'd like to take this along with #57345: same split-reply leak, and the fix most likely belongs in the @opentui/core stdin parser. I asked on #57345 where you'd like it fixed; I'll handle both there once that's settled.

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