summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-04-20 00:10:31 +0200
committerGitHub <[email protected]>2026-04-20 00:10:31 +0200
commita824064c4c7c2b43e4b59da0f578932faca7b26a (patch)
tree9183ba738fff22f536964a29ec8a325429e48377
parent33b2795cc84c79e91e15549609713567eb08348a (diff)
downloadopencode-a824064c4c7c2b43e4b59da0f578932faca7b26a.tar.gz
opencode-a824064c4c7c2b43e4b59da0f578932faca7b26a.zip
stabilize TUI theme persistence and KV writes (#23188)
-rw-r--r--bun.lock28
-rw-r--r--packages/opencode/package.json4
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx8
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/kv.tsx32
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx12
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/terminal.ts39
-rw-r--r--packages/plugin/package.json8
7 files changed, 57 insertions, 74 deletions
diff --git a/bun.lock b/bun.lock
index fd4544f7e..fc9662796 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/[email protected]", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
- "@opentui/core": ["@opentui/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
+ "@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HtqZh8TIKCH1Nge5J0etBCpzYfPY4fVcq110uJm2As6D/dTTPv8r4J+KkrqoSphkpj/Y2b4t7KpqNHthXA0EVw=="],
- "@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
+ "@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-o5ClQWnGG1inRE2YZAatPw1jPEAJni00amcoIfKBj8e1WS+fQA+iQTq1xFunNcyNPObLDCVuW1X+NrbK9xmPvQ=="],
- "@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
+ "@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/weY7DQpaPWGYDPD0CROHowUotqnVlk7Kb6l9+iZCrxm9s7HPRHkcMDVmcWDqHEqa/J879EJcqaUDzDArqC+w=="],
- "@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
+ "@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+Bfr8jLbbR1WREUMCCvSZ44G1+WU2lPqJx7x1StTa9iFNEdicxCdd0QQsO6cnKn5yW+2Pr/FdrqHbxSQw3ejbA=="],
- "@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
+ "@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-LTMIHJzJrVqS8mgpp+tuyVHuqYlicQTvFi/sTsJ6Xswf1asatsvZYsbQByhBLpFT80j10G7uvDa361S5gjCUDA=="],
- "@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
+ "@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-VaMs5bg6y0tYKptaEK8Hy5wTp4m//wJRKUdW8uvrS9cFgxyovZGuw0+TfK3NgbdeX+8jWm8LEAiak4jle5BABg=="],
- "@opentui/solid": ["@opentui/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 6d5abbbbd..42f30b45e 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 a58ff0564..5da2740cc 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 803752e76..43266315b 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<Record<string, any>>()
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<Record<string, any>>(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<string, any>) {
+ 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<Record<string, any>>(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 04670429d..af9582cfb 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 46cf4635a..c026b7381 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 3beea3620..c73addc47 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:",