feat(tui): add persistent storage context

This commit is contained in:
Dax Raad 2026-07-29 12:32:24 -04:00
parent 813c41ff6c
commit 9ee337469d
7 changed files with 280 additions and 223 deletions

View file

@ -21,6 +21,16 @@ import type {
} from "@opencode-ai/client"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
}
interface LocationCollection<Value> {
list(location?: LocationRef): Value[] | undefined
@ -348,5 +358,6 @@ export interface Context {
readonly attention: Attention
readonly theme: any
readonly keymap: Keymap
readonly storage: Storage
readonly ui: UI
}

View file

@ -20,6 +20,7 @@
"./context/exit": "./src/context/exit.tsx",
"./context/log": "./src/context/log.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/storage": "./src/context/storage.tsx",
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
"./theme/discovery": "./src/theme/discovery.ts",

View file

@ -90,6 +90,7 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
registerOpencodeSpinner()
@ -303,104 +304,106 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
worktree: global.data + "/worktree",
}}
>
<TuiLifecycleProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiTerminalEnvironmentProvider
<StorageProvider>
<TuiLifecycleProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</StorageProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiAppProvider>

View file

@ -127,6 +127,9 @@ export const Info = Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share session tabs globally or keep a separate set for each working directory",
}),
}),
).annotate({ description: "Session tab settings" }),
mini: Schema.optional(

View file

@ -1,15 +1,12 @@
import { batch, createEffect, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import path from "path"
import { createEffect, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useData } from "./data"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useTuiPaths } from "./runtime"
import { useConfig } from "../config"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { isRecord } from "../util/record"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import {
closeSessionTab,
cycleSessionTab,
@ -21,11 +18,18 @@ import {
type SessionTabUnread,
} from "./session-tabs-model"
type PersistedState = {
type TabsState = {
tabs: SessionTab[]
unread: Record<string, SessionTabUnread>
}
type PersistedState = {
global: TabsState
cwd: Record<string, TabsState>
}
const empty = (): TabsState => ({ tabs: [], unread: {} })
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
name: "SessionTabs",
init: () => {
@ -33,21 +37,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const data = useData()
const event = useEvent()
const config = useConfig().data
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
const paths = useTuiPaths()
const enabled = () => config.tabs?.enabled ?? false
const state: {
pending: boolean
saving: boolean
snapshot: string
value?: PersistedState
} = { pending: false, saving: false, snapshot: "" }
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
ready: false,
tabs: [],
unread: {},
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
initial: {
global: empty(),
cwd: {},
},
})
const fallback = empty()
let history: SessionTabHistory = { entries: [], index: -1 }
function state() {
if (config.tabs?.scope !== "cwd") return store.global
return store.cwd[paths.cwd] ?? fallback
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
() => {},
)
}
const root = (sessionID: string) => data.session.root(sessionID)
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const status = (sessionID: string) => {
@ -55,7 +67,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
return {
unread: store.unread[session],
unread: state().unread[session],
attention: family.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
@ -63,125 +75,54 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
}
function save() {
if (!store.ready) {
state.pending = true
return
}
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
const snapshot = JSON.stringify(value)
if (snapshot === state.snapshot && !state.saving) return
state.value = value
state.pending = true
flush()
}
function flush() {
if (state.saving || !state.pending || !state.value) return
const value = state.value
const snapshot = JSON.stringify(value)
state.pending = false
if (snapshot === state.snapshot) return
state.saving = true
void writeJsonAtomic(filePath, value)
.then(() => {
state.snapshot = snapshot
})
.catch(() => {})
.finally(() => {
state.saving = false
flush()
})
}
function open(sessionID: string) {
const session = root(sessionID)
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
if (next === store.tabs) return { sessionID: session, changed: false }
setStore("tabs", reconcile(next))
return { sessionID: session, changed: true }
}
function clearUnread(sessionID: string) {
const session = root(sessionID)
if (!store.unread[session]) return false
setStore(
"unread",
produce((draft) => {
delete draft[session]
}),
)
return true
}
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return
const session = root(sessionID)
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
if (store.unread[session] === unread) return
setStore("unread", session, unread)
save()
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
if (state().unread[session] === unread) return
update((draft) => {
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
draft.unread[session] = unread
})
}
readJson<unknown>(filePath)
.then((value) => {
if (!isRecord(value)) return
const persisted = value
if (Array.isArray(persisted.tabs))
setStore(
"tabs",
persisted.tabs.flatMap((tab) => {
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
}),
)
if (persisted.unread && typeof persisted.unread === "object")
setStore(
"unread",
Object.fromEntries(
Object.entries(persisted.unread).filter(
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
),
),
)
})
.catch(() => {})
.finally(() => {
setStore("ready", true)
if (state.pending) save()
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
})
createEffect(() => {
if (!enabled()) return
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
const routeSessionID = route.data.sessionID
batch(() => {
const opened = open(routeSessionID)
history = recordSessionTabHistory(history, opened.sessionID)
const changed = clearUnread(opened.sessionID)
if (opened.changed || changed) untrack(save)
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const title = data.session.get(sessionID)?.title
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
delete draft.unread[sessionID]
})
})
createEffect(() => {
if (!enabled() || !store.ready) return
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
if (!enabled()) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
batch(() => {
setStore("tabs", reconcile(next))
setStore("unread", reconcile(unread))
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
update((draft) => {
draft.tabs = draft.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
draft.unread = Object.entries(draft.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
})
save()
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
@ -200,26 +141,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
const closed = closeSessionTab(store.tabs, target)
if (closed.tabs.length === store.tabs.length) return
const closed = closeSessionTab(state().tabs, target)
if (closed.tabs.length === state().tabs.length) return
const selected = navigate && current() === target
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
: { history, sessionID: undefined }
const next = previous.sessionID ?? closed.next
history = previous.history
batch(() => {
setStore("tabs", reconcile(closed.tabs))
clearUnread(target)
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
update((draft) => {
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
})
save()
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
return {
enabled,
tabs() {
return store.tabs
return state().tabs
},
current,
status,
@ -231,7 +171,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
if (!target) {
const previous = store.tabs.at(-1)
const previous = state().tabs.at(-1)
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
return
}
@ -239,13 +179,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(store.tabs, current(), direction)
const tab = cycleSessionTab(state().tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
)
@ -253,13 +193,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
history(direction: 1 | -1) {
if (!enabled()) return
const next = moveSessionTabHistory(history, store.tabs, current(), direction)
const next = moveSessionTabHistory(history, state().tabs, current(), direction)
history = next.history
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
},
selectIndex(index: number) {
if (!enabled()) return
const tab = store.tabs[index]
const tab = state().tabs[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
}

View file

@ -0,0 +1,94 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
import { writeJsonAtomic } from "../util/persistence"
import { useTuiApp, useTuiPaths } from "./runtime"
type Options<Value extends object> = {
readonly initial: Value
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
}
function clone<Value extends object>(value: Value) {
const json = JSON.stringify(value)
if (json === undefined) throw new TypeError("Storage values must be JSON-compatible objects")
const result = JSON.parse(json) as Value
if (typeof result !== "object" || result === null) throw new TypeError("Storage values must be objects")
return result as Value
}
function segment(value: string) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) || value === "." || value === "..")
throw new TypeError(`Invalid storage segment: ${value}`)
return value
}
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
const storage: Storage = {
store<Value extends object>(key: string, options: Options<Value>) {
const file = path.join(directory, segment(key) + ".json")
const existing = entries.get(file)
if (existing) return existing.value as Entry<Value>
const load = () => {
try {
return clone(JSON.parse(readFileSync(file, "utf8")) as Value)
} catch {
return clone(options.initial)
}
}
const [store, setStore] = createStore(load())
const reload = () => batch(() => setStore(reconcile(load())))
const update = (mutation: (draft: Value) => void) =>
Flock.withLock(
file,
async () => {
const draft = load()
mutation(draft)
const next = clone(draft)
await writeJsonAtomic(file, next)
batch(() => setStore(reconcile(next)))
},
{ dir: locks },
)
const entry = [store, update] as const
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
return {
storage,
close: () => watcher.close(),
}
}
const Context = createContext<Storage>()
export function StorageProvider(props: ParentProps) {
const result = createStorage(path.join(useTuiPaths().state, "storage"), useTuiApp().channel)
onCleanup(result.close)
return <Context.Provider value={result.storage}>{props.children}</Context.Provider>
}
export function useStorage() {
const storage = useContext(Context)
if (!storage) throw new Error("StorageProvider is missing")
return storage
}

View file

@ -32,6 +32,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { abbreviateHome } from "../util/path-format"
import { builtins } from "./builtins"
import { discoverTuiPlugins } from "./discovery"
@ -96,6 +97,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
const storage = useStorage()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@ -234,6 +236,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
active: keymapState.active,
mode: keymap.mode,
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
toast: toastApi,