From a824064c4c7c2b43e4b59da0f578932faca7b26a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 20 Apr 2026 00:10:31 +0200 Subject: [PATCH] stabilize TUI theme persistence and KV writes (#23188) --- bun.lock | 28 ++++++------- packages/opencode/package.json | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 8 +--- .../opencode/src/cli/cmd/tui/context/kv.tsx | 32 +++++++++++++-- .../src/cli/cmd/tui/context/theme.tsx | 12 ++++-- .../opencode/src/cli/cmd/tui/util/terminal.ts | 39 ------------------- packages/plugin/package.json | 8 ++-- 7 files changed, 57 insertions(+), 74 deletions(-) diff --git a/bun.lock b/bun.lock index fd4544f7eb..fc96627961 100644 --- a/bun.lock +++ b/bun.lock @@ -366,8 +366,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.101", + "@opentui/solid": "0.1.101", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -465,16 +465,16 @@ "zod": "catalog:", }, "devDependencies": { - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.101", + "@opentui/solid": "0.1.101", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.1.100", - "@opentui/solid": ">=0.1.100", + "@opentui/core": ">=0.1.101", + "@opentui/solid": ">=0.1.101", }, "optionalPeers": [ "@opentui/core", @@ -1600,21 +1600,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="], + "@opentui/core": ["@opentui/core@0.1.101", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.101", "@opentui/core-darwin-x64": "0.1.101", "@opentui/core-linux-arm64": "0.1.101", "@opentui/core-linux-x64": "0.1.101", "@opentui/core-win32-arm64": "0.1.101", "@opentui/core-win32-x64": "0.1.101", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8jUhNKnwCDO3Y2iiEmagoQLjgX5l1WbddQiwky8B5JU4FW0/WRHairBmU1kRAQBmhdeg57dVinSG4iu2PAtKEA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.99", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.101", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HtqZh8TIKCH1Nge5J0etBCpzYfPY4fVcq110uJm2As6D/dTTPv8r4J+KkrqoSphkpj/Y2b4t7KpqNHthXA0EVw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.99", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.101", "", { "os": "darwin", "cpu": "x64" }, "sha512-o5ClQWnGG1inRE2YZAatPw1jPEAJni00amcoIfKBj8e1WS+fQA+iQTq1xFunNcyNPObLDCVuW1X+NrbK9xmPvQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.99", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.101", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/weY7DQpaPWGYDPD0CROHowUotqnVlk7Kb6l9+iZCrxm9s7HPRHkcMDVmcWDqHEqa/J879EJcqaUDzDArqC+w=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.99", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.101", "", { "os": "linux", "cpu": "x64" }, "sha512-+Bfr8jLbbR1WREUMCCvSZ44G1+WU2lPqJx7x1StTa9iFNEdicxCdd0QQsO6cnKn5yW+2Pr/FdrqHbxSQw3ejbA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.99", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.101", "", { "os": "win32", "cpu": "arm64" }, "sha512-LTMIHJzJrVqS8mgpp+tuyVHuqYlicQTvFi/sTsJ6Xswf1asatsvZYsbQByhBLpFT80j10G7uvDa361S5gjCUDA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.99", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.101", "", { "os": "win32", "cpu": "x64" }, "sha512-VaMs5bg6y0tYKptaEK8Hy5wTp4m//wJRKUdW8uvrS9cFgxyovZGuw0+TfK3NgbdeX+8jWm8LEAiak4jle5BABg=="], - "@opentui/solid": ["@opentui/solid@0.1.99", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="], + "@opentui/solid": ["@opentui/solid@0.1.101", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.101", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-STY2FQYtVS2rhUgpslG6mM0EAkgobBDF91+B+SNmvXIkJwP+ydP6UVgcuIo5McIbb9GIbAODx5X2Q48PSR7hgw=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6d5abbbbdb..42f30b45ef 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -122,8 +122,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.101", + "@opentui/solid": "0.1.101", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a58ff05648..5da2740cce 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,7 +1,6 @@ import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" -import * as Terminal from "@tui/util/terminal" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { @@ -120,12 +119,6 @@ export function tui(input: { const unguard = win32InstallCtrlCGuard() win32DisableProcessedInput() - const mode = await Terminal.getTerminalBackgroundColor() - - // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores - // the original console mode which re-enables ENABLE_PROCESSED_INPUT. - win32DisableProcessedInput() - const onExit = async () => { unguard?.() resolve() @@ -136,6 +129,7 @@ export function tui(input: { } const renderer = await createCliRenderer(rendererConfig(input.config)) + const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" await render(() => { return ( diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 803752e766..43266315bf 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,7 +1,9 @@ import { Global } from "@/global" import { Filesystem } from "@/util" +import { Flock } from "@opencode-ai/shared/util/flock" +import { rename, rm } from "fs/promises" import { createSignal, type Setter } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, unwrap } from "solid-js/store" import { createSimpleContext } from "./helper" import path from "path" @@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ const [ready, setReady] = createSignal(false) const [store, setStore] = createStore>() const filePath = path.join(Global.Path.state, "kv.json") + const lock = `tui-kv:${filePath}` + // Queue same-process writes so rapid updates persist in order. + let write = Promise.resolve() - Filesystem.readJson>(filePath) + // Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence. + function writeSnapshot(snapshot: Record) { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp` + return Filesystem.writeJson(tempPath, snapshot) + .then(() => rename(tempPath, filePath)) + .catch(async (error) => { + await rm(tempPath, { force: true }).catch(() => undefined) + throw error + }) + } + + // Read under the same lock used for writes because kv.json is shared across processes. + Flock.withLock(lock, () => Filesystem.readJson>(filePath)) .then((x) => { setStore(x) }) - .catch(() => {}) + .catch((error) => { + console.error("Failed to read KV state", { filePath, error }) + }) .finally(() => { setReady(true) }) @@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ }, set(key: string, value: any) { setStore(key, value) - void Filesystem.writeJson(filePath, store) + const snapshot = structuredClone(unwrap(store)) + write = write + .then(() => Flock.withLock(lock, () => writeSnapshot(snapshot))) + .catch((error) => { + console.error("Failed to write KV state", { filePath, error }) + }) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 04670429da..af9582cfb0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -314,8 +314,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setStore( produce((draft) => { const lock = pick(kv.get("theme_mode_lock")) - const mode = pick(kv.get("theme_mode", props.mode)) - draft.mode = lock ?? mode ?? props.mode + const mode = lock ?? props.mode + if (!lock && pick(kv.get("theme_mode")) !== undefined) { + kv.set("theme_mode", undefined) + } + draft.mode = mode draft.lock = lock const active = config.theme ?? kv.get("theme", "opencode") draft.active = typeof active === "string" ? active : "opencode" @@ -373,7 +376,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } function apply(mode: "dark" | "light") { - kv.set("theme_mode", mode) + if (store.lock !== undefined) kv.set("theme_mode", mode) if (store.mode === mode) return setStore("mode", mode) renderer.clearPaletteCache() @@ -389,6 +392,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function free() { setStore("lock", undefined) kv.set("theme_mode_lock", undefined) + kv.set("theme_mode", undefined) const mode = renderer.themeMode if (mode) apply(mode) } @@ -397,7 +401,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.lock) return apply(mode) } - // renderer.on(CliRenderEvents.THEME_MODE, handle) + renderer.on(CliRenderEvents.THEME_MODE, handle) const refresh = () => { renderer.clearPaletteCache() diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index 46cf4635a7..c026b7381c 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -17,12 +17,6 @@ function parse(color: string): RGBA | null { return null } -function mode(bg: RGBA | null): "dark" | "light" { - if (!bg) return "dark" - const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 - return luminance > 0.5 ? "light" : "dark" -} - /** * Query terminal colors including background, foreground, and palette (0-15). * Uses OSC escape sequences to retrieve actual terminal color values. @@ -100,36 +94,3 @@ export async function colors(): Promise<{ }, 1000) }) } - -// Keep startup mode detection separate from `colors()`: the TUI boot path only -// needs OSC 11 and should resolve on the first background response instead of -// waiting on the full palette query used by system theme generation. -export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { - if (!process.stdin.isTTY) return "dark" - - return new Promise((resolve) => { - let timeout: NodeJS.Timeout - - const cleanup = () => { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", handler) - clearTimeout(timeout) - } - - const handler = (data: Buffer) => { - const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) - if (!match) return - cleanup() - resolve(mode(parse(match[1]))) - } - - process.stdin.setRawMode(true) - process.stdin.on("data", handler) - process.stdout.write("\x1b]11;?\x07") - - timeout = setTimeout(() => { - cleanup() - resolve("dark") - }, 1000) - }) -} diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3beea3620b..c73addc478 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.1.100", - "@opentui/solid": ">=0.1.100" + "@opentui/core": ">=0.1.101", + "@opentui/solid": ">=0.1.101" }, "peerDependenciesMeta": { "@opentui/core": { @@ -34,8 +34,8 @@ } }, "devDependencies": { - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.101", + "@opentui/solid": "0.1.101", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "typescript": "catalog:",