Skip to content

Commit a824064

Browse files
authored
stabilize TUI theme persistence and KV writes (#23188)
1 parent 33b2795 commit a824064

7 files changed

Lines changed: 57 additions & 74 deletions

File tree

bun.lock

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@
122122
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
123123
"@opentelemetry/sdk-trace-base": "2.6.1",
124124
"@opentelemetry/sdk-trace-node": "2.6.1",
125-
"@opentui/core": "catalog:",
126-
"@opentui/solid": "catalog:",
125+
"@opentui/core": "0.1.101",
126+
"@opentui/solid": "0.1.101",
127127
"@parcel/watcher": "2.5.1",
128128
"@pierre/diffs": "catalog:",
129129
"@solid-primitives/event-bus": "1.1.2",

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
22
import * as Clipboard from "@tui/util/clipboard"
33
import * as Selection from "@tui/util/selection"
4-
import * as Terminal from "@tui/util/terminal"
54
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
65
import { RouteProvider, useRoute } from "@tui/context/route"
76
import {
@@ -120,12 +119,6 @@ export function tui(input: {
120119
const unguard = win32InstallCtrlCGuard()
121120
win32DisableProcessedInput()
122121

123-
const mode = await Terminal.getTerminalBackgroundColor()
124-
125-
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
126-
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
127-
win32DisableProcessedInput()
128-
129122
const onExit = async () => {
130123
unguard?.()
131124
resolve()
@@ -136,6 +129,7 @@ export function tui(input: {
136129
}
137130

138131
const renderer = await createCliRenderer(rendererConfig(input.config))
132+
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
139133

140134
await render(() => {
141135
return (

packages/opencode/src/cli/cmd/tui/context/kv.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Global } from "@/global"
22
import { Filesystem } from "@/util"
3+
import { Flock } from "@opencode-ai/shared/util/flock"
4+
import { rename, rm } from "fs/promises"
35
import { createSignal, type Setter } from "solid-js"
4-
import { createStore } from "solid-js/store"
6+
import { createStore, unwrap } from "solid-js/store"
57
import { createSimpleContext } from "./helper"
68
import path from "path"
79

@@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
1113
const [ready, setReady] = createSignal(false)
1214
const [store, setStore] = createStore<Record<string, any>>()
1315
const filePath = path.join(Global.Path.state, "kv.json")
16+
const lock = `tui-kv:${filePath}`
17+
// Queue same-process writes so rapid updates persist in order.
18+
let write = Promise.resolve()
1419

15-
Filesystem.readJson<Record<string, any>>(filePath)
20+
// Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence.
21+
function writeSnapshot(snapshot: Record<string, any>) {
22+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
23+
return Filesystem.writeJson(tempPath, snapshot)
24+
.then(() => rename(tempPath, filePath))
25+
.catch(async (error) => {
26+
await rm(tempPath, { force: true }).catch(() => undefined)
27+
throw error
28+
})
29+
}
30+
31+
// Read under the same lock used for writes because kv.json is shared across processes.
32+
Flock.withLock(lock, () => Filesystem.readJson<Record<string, any>>(filePath))
1633
.then((x) => {
1734
setStore(x)
1835
})
19-
.catch(() => {})
36+
.catch((error) => {
37+
console.error("Failed to read KV state", { filePath, error })
38+
})
2039
.finally(() => {
2140
setReady(true)
2241
})
@@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
4463
},
4564
set(key: string, value: any) {
4665
setStore(key, value)
47-
void Filesystem.writeJson(filePath, store)
66+
const snapshot = structuredClone(unwrap(store))
67+
write = write
68+
.then(() => Flock.withLock(lock, () => writeSnapshot(snapshot)))
69+
.catch((error) => {
70+
console.error("Failed to write KV state", { filePath, error })
71+
})
4872
},
4973
}
5074
return result

packages/opencode/src/cli/cmd/tui/context/theme.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
314314
setStore(
315315
produce((draft) => {
316316
const lock = pick(kv.get("theme_mode_lock"))
317-
const mode = pick(kv.get("theme_mode", props.mode))
318-
draft.mode = lock ?? mode ?? props.mode
317+
const mode = lock ?? props.mode
318+
if (!lock && pick(kv.get("theme_mode")) !== undefined) {
319+
kv.set("theme_mode", undefined)
320+
}
321+
draft.mode = mode
319322
draft.lock = lock
320323
const active = config.theme ?? kv.get("theme", "opencode")
321324
draft.active = typeof active === "string" ? active : "opencode"
@@ -373,7 +376,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
373376
}
374377

375378
function apply(mode: "dark" | "light") {
376-
kv.set("theme_mode", mode)
379+
if (store.lock !== undefined) kv.set("theme_mode", mode)
377380
if (store.mode === mode) return
378381
setStore("mode", mode)
379382
renderer.clearPaletteCache()
@@ -389,6 +392,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
389392
function free() {
390393
setStore("lock", undefined)
391394
kv.set("theme_mode_lock", undefined)
395+
kv.set("theme_mode", undefined)
392396
const mode = renderer.themeMode
393397
if (mode) apply(mode)
394398
}
@@ -397,7 +401,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
397401
if (store.lock) return
398402
apply(mode)
399403
}
400-
// renderer.on(CliRenderEvents.THEME_MODE, handle)
404+
renderer.on(CliRenderEvents.THEME_MODE, handle)
401405

402406
const refresh = () => {
403407
renderer.clearPaletteCache()

packages/opencode/src/cli/cmd/tui/util/terminal.ts

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ function parse(color: string): RGBA | null {
1717
return null
1818
}
1919

20-
function mode(bg: RGBA | null): "dark" | "light" {
21-
if (!bg) return "dark"
22-
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
23-
return luminance > 0.5 ? "light" : "dark"
24-
}
25-
2620
/**
2721
* Query terminal colors including background, foreground, and palette (0-15).
2822
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -100,36 +94,3 @@ export async function colors(): Promise<{
10094
}, 1000)
10195
})
10296
}
103-
104-
// Keep startup mode detection separate from `colors()`: the TUI boot path only
105-
// needs OSC 11 and should resolve on the first background response instead of
106-
// waiting on the full palette query used by system theme generation.
107-
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
108-
if (!process.stdin.isTTY) return "dark"
109-
110-
return new Promise((resolve) => {
111-
let timeout: NodeJS.Timeout
112-
113-
const cleanup = () => {
114-
process.stdin.setRawMode(false)
115-
process.stdin.removeListener("data", handler)
116-
clearTimeout(timeout)
117-
}
118-
119-
const handler = (data: Buffer) => {
120-
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
121-
if (!match) return
122-
cleanup()
123-
resolve(mode(parse(match[1])))
124-
}
125-
126-
process.stdin.setRawMode(true)
127-
process.stdin.on("data", handler)
128-
process.stdout.write("\x1b]11;?\x07")
129-
130-
timeout = setTimeout(() => {
131-
cleanup()
132-
resolve("dark")
133-
}, 1000)
134-
})
135-
}

packages/plugin/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
"zod": "catalog:"
2323
},
2424
"peerDependencies": {
25-
"@opentui/core": ">=0.1.100",
26-
"@opentui/solid": ">=0.1.100"
25+
"@opentui/core": ">=0.1.101",
26+
"@opentui/solid": ">=0.1.101"
2727
},
2828
"peerDependenciesMeta": {
2929
"@opentui/core": {
@@ -34,8 +34,8 @@
3434
}
3535
},
3636
"devDependencies": {
37-
"@opentui/core": "catalog:",
38-
"@opentui/solid": "catalog:",
37+
"@opentui/core": "0.1.101",
38+
"@opentui/solid": "0.1.101",
3939
"@tsconfig/node22": "catalog:",
4040
"@types/node": "catalog:",
4141
"typescript": "catalog:",

0 commit comments

Comments
 (0)