Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
})
Expand Down
87 changes: 74 additions & 13 deletions packages/opencode/src/cli/cmd/tui/context/kv.tsx
Original file line number Diff line number Diff line change
@@ -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: () => {
Expand All @@ -25,21 +70,37 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
get ready() {
return ready()
},
signal<T>(name: string, defaultValue: T) {
if (!kvStore[name]) setKvStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
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<K extends keyof KVSchema>(
key: K,
defaultValue: KVSchema[K],
): [Accessor<KVSchema[K]>, (next: KVSchema[K] | ((prev: KVSchema[K]) => KVSchema[K])) => void] {
const initial = (kvStore[key] ?? defaultValue) as KVSchema[K]
const [value, setValue] = createSignal<KVSchema[K]>(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))
},
Expand Down
57 changes: 21 additions & 36 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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()
},
},
Expand All @@ -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()
},
},
Expand All @@ -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()
},
},
Expand All @@ -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()
},
},
Expand All @@ -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()
},
},
Expand All @@ -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()
},
},
Expand Down
154 changes: 154 additions & 0 deletions packages/opencode/test/cli/tui/kv-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {})
}
})
})
Loading