diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2af5b21152c..ffd75e7fec6 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -196,7 +196,7 @@ function App() { .catch(toast.error) renderer.clearSelection() } - const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) + const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true) createEffect(() => { console.log(JSON.stringify(route.data)) @@ -517,7 +517,6 @@ function App() { onSelect: (dialog) => { setTerminalTitleEnabled((prev) => { const next = !prev - kv.set("terminal_title_enabled", next) if (!next) renderer.setTerminalTitle("") return next }) diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 24a9a5544e1..fd3a7cb5cc0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,9 +1,54 @@ import { Global } from "@/global" -import { createSignal, type Setter } from "solid-js" +import { createSignal, type Accessor, type Setter } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" import path from "path" +/** + * Schema for all known KV store keys with their value types. + * This provides type safety for persisted UI settings. + */ +export interface KVSchema { + /** Sidebar visibility mode */ + sidebar: "show" | "hide" | "auto" + + /** Whether to show assistant thinking process */ + thinking_visibility: boolean + + /** Whether to show message timestamps */ + timestamps: "show" | "hide" + + /** Whether to show tool input/output details */ + tool_details_visibility: boolean + + /** Whether to show assistant metadata */ + assistant_metadata_visibility: boolean + + /** Whether to show the session scrollbar */ + scrollbar_visible: boolean + + /** Whether to enable animations */ + animations_enabled: boolean + + /** Whether to update the terminal window title */ + terminal_title_enabled: boolean + + /** Whether to hide tips on home screen */ + tips_hidden: boolean + + /** Whether user has dismissed the getting started panel */ + dismissed_getting_started: boolean + + /** Whether user has seen the OpenRouter warning */ + openrouter_warning: boolean + + /** Theme mode (dark or light) */ + theme_mode: "dark" | "light" + + /** Active theme name */ + theme: string +} + export const { use: useKV, provider: KVProvider } = createSimpleContext({ name: "KV", init: () => { @@ -25,21 +70,37 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ get ready() { return ready() }, - signal(name: string, defaultValue: T) { - if (!kvStore[name]) setKvStore(name, defaultValue) - return [ - function () { - return result.get(name) - }, - function setter(next: Setter) { - result.set(name, next) - }, - ] as const + + /** + * Creates a persisted signal that automatically syncs with the KV store. + * Returns a standard SolidJS signal tuple [accessor, setter]. + * + * @example + * const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) + * setShowThinking(prev => !prev) // Automatically persists to kv.json + */ + signal( + key: K, + defaultValue: KVSchema[K], + ): [Accessor, (next: KVSchema[K] | ((prev: KVSchema[K]) => KVSchema[K])) => void] { + const initial = (kvStore[key] ?? defaultValue) as KVSchema[K] + const [value, setValue] = createSignal(initial) + + const setter = (next: KVSchema[K] | ((prev: KVSchema[K]) => KVSchema[K])) => { + const newValue = typeof next === "function" ? (next as (prev: KVSchema[K]) => KVSchema[K])(value()) : next + setKvStore(key, newValue) + Bun.write(file, JSON.stringify(kvStore, null, 2)) + setValue(() => newValue) + } + + return [value, setter] }, - get(key: string, defaultValue?: any) { + + get(key: string, defaultValue?: any): any { return kvStore[key] ?? defaultValue }, - set(key: string, value: any) { + + set(key: string, value: any): void { setKvStore(key, value) Bun.write(file, JSON.stringify(kvStore, null, 2)) }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 659892b17f7..02e370b91eb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -136,15 +136,17 @@ export function Session() { }) const dimensions = useTerminalDimensions() - const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto")) + const [sidebar, setSidebar] = kv.signal("sidebar", "auto") const [conceal, setConceal] = createSignal(true) - const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true)) - const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") - const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) - const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true)) - const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) + const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) + const [showTimestampsRaw, setShowTimestamps] = kv.signal("timestamps", "hide") + const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) + const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) + const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") - const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) + const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + + const showTimestamps = createMemo(() => showTimestampsRaw() === "show") const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -454,12 +456,13 @@ export function Session() { category: "Session", onSelect: (dialog) => { setSidebar((prev) => { - if (prev === "auto") return sidebarVisible() ? "hide" : "show" - if (prev === "show") return "hide" - return "show" + let next: "show" | "hide" | "auto" + if (prev === "auto") next = sidebarVisible() ? "hide" : "show" + else if (prev === "show") next = "hide" + else next = "show" + kv.set("sidebar", next === "show" ? "auto" : next) + return next }) - if (sidebar() === "show") kv.set("sidebar", "auto") - if (sidebar() === "hide") kv.set("sidebar", "hide") dialog.clear() }, }, @@ -474,15 +477,11 @@ export function Session() { }, }, { - title: showTimestamps() ? "Hide timestamps" : "Show timestamps", + title: showTimestampsRaw() === "show" ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", category: "Session", onSelect: (dialog) => { - setShowTimestamps((prev) => { - const next = !prev - kv.set("timestamps", next ? "show" : "hide") - return next - }) + setShowTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() }, }, @@ -491,11 +490,7 @@ export function Session() { value: "session.toggle.thinking", category: "Session", onSelect: (dialog) => { - setShowThinking((prev) => { - const next = !prev - kv.set("thinking_visibility", next) - return next - }) + setShowThinking((prev) => !prev) dialog.clear() }, }, @@ -514,9 +509,7 @@ export function Session() { keybind: "tool_details", category: "Session", onSelect: (dialog) => { - const newValue = !showDetails() - setShowDetails(newValue) - kv.set("tool_details_visibility", newValue) + setShowDetails((prev) => !prev) dialog.clear() }, }, @@ -526,11 +519,7 @@ export function Session() { keybind: "scrollbar_toggle", category: "Session", onSelect: (dialog) => { - setShowScrollbar((prev) => { - const next = !prev - kv.set("scrollbar_visible", next) - return next - }) + setShowScrollbar((prev) => !prev) dialog.clear() }, }, @@ -539,11 +528,7 @@ export function Session() { value: "session.toggle.animations", category: "Session", onSelect: (dialog) => { - setAnimationsEnabled((prev) => { - const next = !prev - kv.set("animations_enabled", next) - return next - }) + setAnimationsEnabled((prev) => !prev) dialog.clear() }, }, diff --git a/packages/opencode/test/cli/tui/kv-integration.test.ts b/packages/opencode/test/cli/tui/kv-integration.test.ts new file mode 100644 index 00000000000..2f985e3ebec --- /dev/null +++ b/packages/opencode/test/cli/tui/kv-integration.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { Global } from "../../../src/global" + +describe("context.kv.integration", () => { + let testStateDir: string + + test("signal persists values to kv.json and reads them back", async () => { + testStateDir = path.join(await fs.mkdtemp(path.join(Global.Path.home, "opencode-kv-test-"))) + process.env.OPENCODE_TEST_HOME = testStateDir + + try { + // Pre-populate kv.json + const statePath = path.join(testStateDir, "opencode/state") + await fs.mkdir(statePath, { recursive: true }) + await Bun.write( + path.join(statePath, "kv.json"), + JSON.stringify({ + sidebar: "hide", + thinking_visibility: false, + timestamps: "show", + }), + ) + + // Now test that we can read it + const file = Bun.file(path.join(statePath, "kv.json")) + const content = await file.json() + expect(content.sidebar).toBe("hide") + expect(content.thinking_visibility).toBe(false) + expect(content.timestamps).toBe("show") + } finally { + delete process.env.OPENCODE_TEST_HOME + await fs.rm(testStateDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + test("kv.json can be written and re-read multiple times", async () => { + testStateDir = path.join(await fs.mkdtemp(path.join(Global.Path.home, "opencode-kv-test-"))) + process.env.OPENCODE_TEST_HOME = testStateDir + + try { + const statePath = path.join(testStateDir, "opencode/state") + await fs.mkdir(statePath, { recursive: true }) + + const kvFile = path.join(statePath, "kv.json") + + // Write initial state + await Bun.write(kvFile, JSON.stringify({ key1: "value1" })) + let content = await Bun.file(kvFile).json() + expect(content.key1).toBe("value1") + + // Update state + await Bun.write(kvFile, JSON.stringify({ key1: "value1", key2: "value2" })) + content = await Bun.file(kvFile).json() + expect(content.key1).toBe("value1") + expect(content.key2).toBe("value2") + + // Update again + await Bun.write(kvFile, JSON.stringify({ key1: "updated", key2: "value2" })) + content = await Bun.file(kvFile).json() + expect(content.key1).toBe("updated") + expect(content.key2).toBe("value2") + } finally { + delete process.env.OPENCODE_TEST_HOME + await fs.rm(testStateDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + test("default values are used when kv.json is empty", async () => { + testStateDir = path.join(await fs.mkdtemp(path.join(Global.Path.home, "opencode-kv-test-"))) + process.env.OPENCODE_TEST_HOME = testStateDir + + try { + const statePath = path.join(testStateDir, "opencode/state") + await fs.mkdir(statePath, { recursive: true }) + await Bun.write(path.join(statePath, "kv.json"), JSON.stringify({})) + + const file = Bun.file(path.join(statePath, "kv.json")) + const content = await file.json() + expect(content).toEqual({}) + } finally { + delete process.env.OPENCODE_TEST_HOME + await fs.rm(testStateDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + test("complex values can be stored in kv.json", async () => { + testStateDir = path.join(await fs.mkdtemp(path.join(Global.Path.home, "opencode-kv-test-"))) + process.env.OPENCODE_TEST_HOME = testStateDir + + try { + const statePath = path.join(testStateDir, "opencode/state") + await fs.mkdir(statePath, { recursive: true }) + + const complexValue = { + settings: { nested: { value: true } }, + array: [1, 2, 3], + mixed: { bool: true, string: "test", number: 42 }, + } + + await Bun.write(path.join(statePath, "kv.json"), JSON.stringify(complexValue)) + const file = Bun.file(path.join(statePath, "kv.json")) + const content = await file.json() + expect(content).toEqual(complexValue) + } finally { + delete process.env.OPENCODE_TEST_HOME + await fs.rm(testStateDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + test("kv.json stores all persisted UI settings correctly", async () => { + testStateDir = path.join(await fs.mkdtemp(path.join(Global.Path.home, "opencode-kv-test-"))) + process.env.OPENCODE_TEST_HOME = testStateDir + + try { + const statePath = path.join(testStateDir, "opencode/state") + await fs.mkdir(statePath, { recursive: true }) + + const allSettings = { + // Session view settings + sidebar: "auto", + thinking_visibility: true, + timestamps: "hide", + tool_details_visibility: true, + assistant_metadata_visibility: true, + scrollbar_visible: false, + animations_enabled: true, + + // App settings + terminal_title_enabled: true, + tips_hidden: false, + dismissed_getting_started: false, + openrouter_warning: false, + + // Theme settings + theme_mode: "dark", + theme: "opencode", + } + + await Bun.write(path.join(statePath, "kv.json"), JSON.stringify(allSettings)) + const file = Bun.file(path.join(statePath, "kv.json")) + const content = await file.json() + + // Verify all keys + Object.entries(allSettings).forEach(([key, value]) => { + expect(content[key]).toBe(value) + }) + } finally { + delete process.env.OPENCODE_TEST_HOME + await fs.rm(testStateDir, { recursive: true, force: true }).catch(() => {}) + } + }) +}) diff --git a/packages/opencode/test/cli/tui/kv-signal-logic.test.ts b/packages/opencode/test/cli/tui/kv-signal-logic.test.ts new file mode 100644 index 00000000000..df1291042a0 --- /dev/null +++ b/packages/opencode/test/cli/tui/kv-signal-logic.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import { createSignal } from "solid-js" + +describe("context.kv.signal-logic", () => { + test("signal setter works with direct values", () => { + const [getter, setter] = createSignal("auto" as "auto" | "show" | "hide") + + expect(getter()).toBe("auto") + + setter("show") + expect(getter()).toBe("show") + }) + + test("signal setter works with functional updates", () => { + const [getter, setter] = createSignal(true) + + expect(getter()).toBe(true) + + setter((prev) => { + expect(prev).toBe(true) + return false + }) + + expect(getter()).toBe(false) + }) + + test("signal setter handles multiple rapid updates", () => { + const [getter, setter] = createSignal(0) + + for (let i = 0; i < 10; i++) { + setter(i) + } + + expect(getter()).toBe(9) + }) + + test("signal setter with string union types", () => { + const [getter, setter] = createSignal("hide" as "show" | "hide") + + expect(getter()).toBe("hide") + + setter("show") + expect(getter()).toBe("show") + + setter("hide") + expect(getter()).toBe("hide") + }) + + test("signal functional update receives correct previous value", () => { + const [getter, setter] = createSignal("auto" as "auto" | "show" | "hide") + + const transitions: Array = [] + + setter((prev) => { + transitions.push(prev) + return prev === "auto" ? "show" : prev === "show" ? "hide" : "auto" + }) + + expect(getter()).toBe("show") + expect(transitions).toEqual(["auto"]) + }) + + test("signal toggling pattern", () => { + const [getter, setter] = createSignal(false) + + // Toggle on + setter((prev) => !prev) + expect(getter()).toBe(true) + + // Toggle off + setter((prev) => !prev) + expect(getter()).toBe(false) + + // Toggle on again + setter((prev) => !prev) + expect(getter()).toBe(true) + }) +})