Skip to content

TUI stays dark on a light terminal after a dark palette was cached #52457

Closed

Description

@x0c

Description

On a light terminal the TUI starts in dark mode with theme.mode: "system" if ~/.local/state/opencode/<channel>/tui/system-theme.json was written while the terminal was dark. At startup the cached palette's mode overrides the mode the terminal just reported (OSC 10/11). With a non-system theme that cache is never refreshed, so it stays dark. v1 was fine; this came with the palette cache added in #48570.

Plugins

No response

OpenCode version

2.0.21

Steps to reproduce

  1. In a dark terminal, use theme system once so the palette is cached.
  2. Switch to another theme (e.g. github), keep mode: "system".
  3. Switch the terminal to a light background and start OpenCode: it renders dark.

Deleting system-theme.json fixes it.

Screenshot and/or share link

No response

Operating System

macOS 27.0

Terminal

any (reproduced in a plain pty and in tmux)

Activity

  1. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

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

  2. x0c commented on Oct 1, 2026

    @x0c
    Author

    Not a duplicate of #42635: that one is about re-detecting after the terminal switches at runtime (1.18). This is v2 at startup: the terminal already answered light, but the cached palette overrides it. Fix in #52459.

  3. DEAN-Cherry commented on Oct 24, 2026

    @DEAN-Cherry

    Still reproduces on v2.1.4, and not macOS-specific.

    • OS: Windows 11 (10.0.26200, x64)
    • Terminal: WezTerm 20260909-081506-9fa147c9 (TERM=xterm-256color)
    • Config: "theme": { "name": "opencode", "mode": "system" }

    The cached file held defaultBackground: "#1e1e2e" (written while the terminal was dark). With the system on light, WezTerm reports light, yet the TUI started dark every time. Deleting ~/.local/state/opencode/latest/tui/system-theme.json fixed it.

    In packages/tui/src/context/theme.tsx at v2.1.4, terminalMode(cachedPalette) still overrides renderer.themeMode at init, while refreshPalette() returns early unless store.active === "system", so the cache can never be corrected for non-system themes. Applying the cached palette's mode only when the active theme is system (or preferring renderer.themeMode when available) should fix it.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the clear report! I can confirm this on 2.0.21, and it still happens on the latest dev (891a020).

    What I did, in a pty that answers the terminal colour queries (OSC 10/11/4) and with mode: "system" in every run:

    1. Light terminal, theme github, no cache: renders light.
    2. Dark terminal, theme system: renders dark and writes system-theme.json with the dark palette.
    3. Light terminal, theme github: renders dark (github's dark variant).
    4. After deleting system-theme.json, the same light terminal with github renders light again.

    The recording below shows all four runs, and the screenshots show step 3 (dark on a light terminal) and step 4 (light after deleting the cache). The script below reproduces it.

    The cause looks like what you describe: at startup the mode from the cached palette overrides the mode the terminal just reported, and the cache is only refreshed while the system theme is active. Until this is fixed, deleting system-theme.json works around it, as you found.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (repro.py)
    #!/usr/bin/env python3
    """Reproduce #52457: cached dark system palette forces dark mode on a light terminal.
    
    Runs `opencode` (or OC_CMD) in a pty that answers OSC 10/11/4 color queries like
    a real terminal, with an isolated HOME. Afterwards it reports which background
    colour the TUI painted most (dark vs light).
    
      python3 repro.py            # uses `opencode` from PATH
      OC_CMD="bun ... index.ts" python3 repro.py
    """
    import base64
    import os, pty, re, select, shlex, subprocess, sys, time, json, shutil, collections
    
    CMD = shlex.split(os.environ.get("OC_CMD", "opencode"))
    ROOT = "/tmp/opencode/repro-52457"
    
    DARK = dict(fg=(0xdd, 0xdd, 0xdd), bg=(0x10, 0x10, 0x10))
    LIGHT = dict(fg=(0x20, 0x20, 0x20), bg=(0xfa, 0xfa, 0xfa))
    BASE16 = [(0,0,0),(205,49,49),(13,188,121),(229,229,16),(36,114,200),(188,63,188),(17,168,205),(229,229,229),
              (102,102,102),(241,76,76),(35,209,139),(245,245,67),(59,142,234),(214,112,214),(41,184,219),(255,255,255)]
    
    TL = None  # optional terminal timeline (opencode-drive recording format) for video export
    T0 = time.time()
    def tl(data):
        if TL: TL.write(json.dumps({"type": "output", "at_ms": int((time.time() - T0) * 1000), "data": base64.b64encode(data).decode()}) + "\n")
    
    def banner(text):
        tl(b"\x1b[0m\x1b[2J\x1b[H\r\n\r\n   " + text.encode() + b"\r\n")
        time.sleep(2.5)
        tl(b"\x1b[0m")
    
    def rgb(c):
        return "rgb:%04x/%04x/%04x" % tuple(v * 257 for v in c)
    
    def run(label, scheme, theme, seconds=int(os.environ.get("OC_WAIT", "8"))):
        cfg = os.path.join(ROOT, "home/.config/opencode")
        os.makedirs(cfg, exist_ok=True)
        with open(os.path.join(cfg, "cli.json"), "w") as f:
            json.dump({"theme": {"name": theme, "mode": "system"}}, f)
        env = dict(os.environ, HOME=os.path.join(ROOT, "home"), TERM="xterm-256color", COLORTERM="truecolor")
        for k in ("XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"):
            env.pop(k, None)
        banner(f"[{label}] terminal background: {'DARK' if scheme is DARK else 'LIGHT'}   theme: {theme}   mode: system   cache files: {len(cache_files())}")
        pid, fd = pty.fork()
        if pid == 0:
            os.chdir(os.path.join(ROOT, "project"))
            os.execvpe(CMD[0], CMD, env)
        import fcntl, termios, struct
        fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
        out = b""
        end = time.time() + seconds
        handled = 0
        while time.time() < end:
            r, _, _ = select.select([fd], [], [], 0.1)
            if not r:
                continue
            try:
                data = os.read(fd, 65536)
            except OSError:
                break
            out += data
            tl(data)
            tail = out[handled:]
            replies = b""
            for m in re.finditer(rb"\x1b\](10|11|4;(\d+));\?(\x07|\x1b\\)", tail):
                what, idx, term = m.group(1), m.group(2), m.group(3)
                if what == b"10": c, code = scheme["fg"], b"10"
                elif what == b"11": c, code = scheme["bg"], b"11"
                else:
                    i = int(idx); c = BASE16[i] if i < 16 else (128, 128, 128); code = b"4;" + idx
                replies += b"\x1b]" + code + b";" + rgb(c).encode() + term
            for m in re.finditer(rb"\x1b\[\?996n", tail):
                replies += b"\x1b[?997;%dn" % (1 if scheme is DARK else 2)
            for m in re.finditer(rb"\x1b\[6n", tail):
                replies += b"\x1b[1;1R"
            for m in re.finditer(rb"\x1b\[c", tail):
                replies += b"\x1b[?62;22c"
            if replies:
                os.write(fd, replies)
            handled = max(handled, len(out) - 64)
        os.kill(pid, 9)
        os.waitpid(pid, 0)
        bgs = collections.Counter(re.findall(rb"\x1b\[48;2;(\d+);(\d+);(\d+)m", out))
        top = bgs.most_common(1)
        if top:
            r, g, b = map(int, top[0][0])
            mode = "LIGHT" if (r + g + b) / 3 > 128 else "DARK"
            print(f"[{label}] terminal={'dark' if scheme is DARK else 'light'} theme={theme}: most painted bg rgb({r},{g},{b}) -> TUI rendered {mode}")
        else:
            print(f"[{label}] no truecolor background output captured ({len(out)} bytes)")
        return out
    
    def cache_files():
        st = os.path.join(ROOT, "home/.local/state")
        return [os.path.join(d, f) for d, _, fs in os.walk(st) for f in fs if f == "system-theme.json"]
    
    if __name__ == "__main__":
        shutil.rmtree(ROOT, ignore_errors=True)
        if os.environ.get("OC_TIMELINE"):
            TL = open(os.environ["OC_TIMELINE"], "w")
            TL.write(json.dumps({"type": "header", "version": 1, "cols": 120, "rows": 40, "encoding": "base64"}) + "\n")
        os.makedirs(os.path.join(ROOT, "project"))
        subprocess.run(["git", "init", "-q"], cwd=os.path.join(ROOT, "project"))
        if os.environ.get("OC_PRELOAD"):  # running from source: background server needs the solid preload too
            with open(os.path.join(ROOT, "project/bunfig.toml"), "w") as f:
                f.write('preload = ["%s"]\n' % os.environ["OC_PRELOAD"])
        # Control: light terminal, named theme, no cache
        run("control", LIGHT, "github")
        print("cache after control:", cache_files())
        # Step 1: dark terminal with theme system -> caches a dark palette
        run("step1", DARK, "system")
        files = cache_files()
        print("cache after step1:", files)
        for f in files:
            print("  ", open(f).read()[:200])
        # Step 2+3: light terminal, theme github, mode system
        run("step3", LIGHT, "github")
        # Workaround: delete cache, run again
        for f in files: os.remove(f)
        run("after-delete", LIGHT, "github")
        if TL:
            banner("done")
            TL.close()

    Run it with opencode-drive: opencode-drive run ./repro.py. It drives opencode from your PATH.

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