refactor(tui): extract standalone package (#31193)

This commit is contained in:
Dax 2026-06-07 01:24:27 -04:00 committed by GitHub
commit 106f8e94d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 1147 additions and 1918 deletions

View file

@ -0,0 +1,18 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
}

View file

@ -1,15 +1,16 @@
import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync"
import { abbreviateHome, useTuiEnvironment } from "../runtime"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() {
const project = useProject()
const sync = useSync()
const environment = useTuiEnvironment()
const paths = useTuiPaths()
return createMemo(() => {
const directory = project.instance.path().directory || environment.cwd
const result = abbreviateHome(directory, environment.paths.home)
const directory = project.instance.path().directory || paths.cwd
const result = abbreviateHome(directory, paths.home)
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result
})

View file

@ -2,9 +2,9 @@ import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { isRecord } from "../util/record"
import { useTuiEnvironment } from "../runtime"
import { useOptionalTuiPlatform } from "../platform"
import { useTuiPaths } from "./runtime"
import { createSimpleContext } from "./helper"
import { editorIntegration } from "../editor"
const MCP_PROTOCOL_VERSION = "2025-11-25"
@ -104,11 +104,20 @@ type EditorConnection = {
source: string
}
export type EditorIntegration = Readonly<{
connection?(directory: string): EditorConnection | undefined
selection?(directory: string): Promise<unknown>
}>
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const environment = useTuiEnvironment()
const platform = useOptionalTuiPlatform()
init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => {
const paths = useTuiPaths()
const editor = props.integration ?? editorIntegration
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
const parsedPort = value ? Number.parseInt(value, 10) : undefined
const port = parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined
const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
@ -130,7 +139,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = environment.cwd
let directory = paths.cwd
let preserveSelectionOnReconnect = false
const pending = new Map<number, string>()
@ -163,20 +172,20 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const connect = () => {
if (closed) return
const connection = resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection)
const connection = resolveEditorConnection(directory, port, editor.connection)
if (!connection) {
if (!environment.editor.zedTerminal) {
if (!zedTerminal) {
setStore("status", "disabled")
scheduleReconnect()
return
}
if (!editor.selection) {
setStore("status", "disabled")
scheduleReconnect()
return
}
if (!platform?.editor?.selection) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= platform.editor
zedSelection ??= editor
.selection(directory)
.then((result) => {
if (closed || socket) return
@ -278,7 +287,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
}
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || environment.cwd
const resolved = nextDirectory || paths.cwd
const sameDirectory = directory === resolved
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
if (sameDirectory) return
@ -311,8 +320,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
return Boolean(
resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) ||
(environment.editor.zedTerminal && platform?.editor?.selection),
resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection),
)
},
connected() {

View file

@ -0,0 +1,6 @@
import { createSimpleContext } from "./helper"
export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({
name: "Epilogue",
init: (props: { set(value?: string): void }) => props.set,
})

View file

@ -1,42 +0,0 @@
import { createSimpleContext } from "./helper"
export type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
get: () => string | undefined
}
}
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
return Object.assign(
(reason?: unknown) => {
task ??= run(reason, store.get)
return task
},
{
message: store,
},
) satisfies Exit
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { exit: Exit }) => input.exit,
})

View file

@ -1,18 +1,25 @@
import { createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useOptionalTuiPlatform } from "../platform"
import { Flock } from "@opencode-ai/core/util/flock"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTuiPaths } from "./runtime"
import path from "path"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: () => {
const platform = useOptionalTuiPlatform()
const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
const lock = `tui-kv:${file}`
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
// Queue same-process writes so rapid updates persist in order.
let write = Promise.resolve()
;(platform?.state?.read() ?? Promise.resolve({}))
;Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((x) => {
setStore(x)
})
@ -48,7 +55,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
setStore(key, value)
const snapshot = structuredClone(unwrap(store))
write = write
.then(() => platform?.state?.write(snapshot))
.then(() =>
Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)),
)
.catch((error) => {
console.error("Failed to write KV state", { error })
})

View file

@ -4,11 +4,14 @@ import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync"
import { useEvent } from "./event"
import path from "path"
import { useTuiEnvironment } from "../runtime"
import { useTuiPaths } from "./runtime"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTheme } from "./theme"
import { useToast } from "../ui/toast"
import { useRoute } from "./route"
export type LocalTheme = {
secondary: RGBA
@ -20,17 +23,6 @@ export type LocalTheme = {
info: RGBA
}
export type LocalDependencies = {
theme: LocalTheme
toast: {
show(options: { variant: "info" | "warning" | "error"; message: string; duration?: number }): void
}
route: {
readonly data: { type: string; sessionID?: string }
navigate(route: { type: "session"; sessionID: string }): void
}
}
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
@ -57,11 +49,13 @@ export function recentModels(
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: (props: LocalDependencies) => {
init: () => {
const sync = useSync()
const sdk = useSDK()
const toast = props.toast
const environment = useTuiEnvironment()
const toast = useToast()
const theme = useTheme().theme
const route = useRoute()
const paths = useTuiPaths()
function isModelValid(model: { providerID: string; modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
@ -82,7 +76,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const theme = props.theme
const colors = createMemo(() => [
theme.secondary,
theme.accent,
@ -164,7 +157,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
variant: {},
})
const filePath = path.join(environment.paths.state, "model.json")
const filePath = path.join(paths.state, "model.json")
const state = {
pending: false,
}
@ -421,7 +414,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
pinned: [],
})
const filePath = path.join(environment.paths.state, "session.json")
const filePath = path.join(paths.state, "session.json")
const state = {
pending: false,
}
@ -453,7 +446,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (state.pending) save()
})
const route = props.route
const event = useEvent()
const slots = createMemo(() => {

View file

@ -1,6 +1,7 @@
import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js"
import { abbreviateHome, useTuiEnvironment } from "../runtime"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
const context = createContext<{
path: () => string
@ -8,12 +9,12 @@ const context = createContext<{
}>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
const environment = useTuiEnvironment()
const paths = useTuiPaths()
return (
<context.Provider
value={{
path: () => props.path || environment.cwd,
format: (input) => formatPath(input, props.path || environment.cwd, environment.paths.home),
path: () => props.path || paths.cwd,
format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
}}
>
{props.children}

View file

@ -1,7 +1,7 @@
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../prompt/history"
import { useTuiEnvironment } from "../runtime"
import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
@ -25,9 +25,9 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { initialRoute?: Route }) => {
const environment = useTuiEnvironment()
const startup = useTuiStartup()
const [store, setStore] = createStore<Route>(
props.initialRoute ?? initialRoute(environment.initialRoute) ?? { type: "home" },
props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" },
)
return {

View file

@ -0,0 +1,62 @@
import { createComponent, createContext, type JSX, useContext } from "solid-js"
export type TuiPaths = Readonly<{
cwd: string
home: string
state: string
worktree: string
}>
export type TuiTerminalEnvironment = Readonly<{
platform: string
multiplexer?: "tmux" | "screen"
displayServer?: "wayland" | "x11"
}>
export type TuiStartup = Readonly<{
initialRoute?: unknown
skipInitialLoading: boolean
}>
const PathsContext = createContext<TuiPaths>()
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
const StartupContext = createContext<TuiStartup>()
function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
return createComponent(context.Provider, {
value: Object.freeze({ ...value }),
get children() {
return children()
},
})
}
export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) {
return provider(PathsContext, props.value, () => props.children)
}
export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
return provider(TerminalEnvironmentContext, props.value, () => props.children)
}
export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) {
return provider(StartupContext, props.value, () => props.children)
}
function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
const value = useContext(context)
if (!value) throw new Error(`${name} is missing`)
return value
}
export function useTuiPaths() {
return required(PathsContext, "TuiPathsProvider")
}
export function useTuiTerminalEnvironment() {
return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
}
export function useTuiStartup() {
return required(StartupContext, "TuiStartupProvider")
}

View file

@ -1,6 +1,6 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { useTuiEnvironment } from "../runtime"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js"
@ -17,7 +17,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
headers?: RequestInit["headers"]
events?: EventSource
}) => {
const environment = useTuiEnvironment()
const abort = new AbortController()
let sse: AbortController | undefined
@ -94,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
sseMaxRetryAttempts: 0,
})
if (environment.capabilities.workspaces) {
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
@ -122,7 +121,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
if (environment.capabilities.workspaces) {
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})

View file

@ -24,13 +24,15 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "./project"
import { useEvent } from "./event"
import { useSDK } from "./sdk"
import { useTuiEnvironment } from "../runtime"
import { useTuiStartup } from "./runtime"
import { createSimpleContext } from "./helper"
import { useExit } from "./exit"
import { useRenderer } from "@opentui/solid"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import path from "path"
import { aggregateFailures } from "./aggregate-failures"
import { useKV } from "./kv"
import { destroyRenderer } from "../util/renderer"
const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
@ -50,19 +52,11 @@ function search<T>(items: T[], target: string, key: (item: T) => string) {
return { found: false, index: left }
}
export type SyncDependencies = {
kv: { get(key: string, defaultValue: boolean): boolean }
logger: { error(message: string, extra?: Record<string, unknown>): void }
}
export const {
context: SyncContext,
use: useSync,
provider: SyncProvider,
} = createSimpleContext({
export const { context: SyncContext, use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: (dependencies: SyncDependencies) => {
const environment = useTuiEnvironment()
init: () => {
const startup = useTuiStartup()
const kv = useKV()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
@ -136,7 +130,6 @@ export const {
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const kv = dependencies.kv
const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
@ -427,7 +420,7 @@ export const {
}
})
const exit = useExit()
const renderer = useRenderer()
const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) {
@ -520,13 +513,13 @@ export const {
})
})
.catch(async (e) => {
dependencies.logger.error("tui bootstrap failed", {
console.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
if (fatal) {
await exit(e)
destroyRenderer(renderer)
} else {
throw e
}
@ -544,7 +537,7 @@ export const {
return store.status
},
get ready() {
if (environment.skipInitialLoading) return true
if (startup.skipInitialLoading) return true
return store.status !== "loading"
},
get path() {

View file

@ -24,7 +24,41 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useKV } from "./kv"
import { useTuiConfig } from "../config"
import { useOptionalTuiPlatform } from "../platform"
import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { readFile } from "node:fs/promises"
import path from "node:path"
export type ThemeSource = Readonly<{
discover(): Promise<Record<string, unknown>>
subscribeRefresh?(refresh: () => void): () => void
}>
const themeSource: ThemeSource = {
async discover() {
const directories = [Global.Path.config]
for (let current = process.cwd(); ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return discoverThemes(directories)
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
}
export async function discoverThemes(directories: string[]) {
const result: Record<string, unknown> = {}
for (const directory of directories) {
const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
for (const file of files) {
result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
}
}
return result
}
export {
DEFAULT_THEMES,
@ -67,11 +101,11 @@ subscribeThemes((themes) => setStore("themes", themes))
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
const renderer = useRenderer()
const config = useTuiConfig()
const kv = useKV()
const platform = useOptionalTuiPlatform()
const themes = props.source ?? themeSource
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
@ -96,7 +130,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function syncCustomThemes() {
return (platform?.themes?.discover() ?? Promise.resolve({}))
return themes
.discover()
.then((themes) => {
setCustomThemes(
Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
@ -207,7 +242,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}, delay),
)
}
const unsubscribeRefresh = platform?.themes?.subscribeRefresh?.(refresh)
let unsubscribeRefresh: (() => void) | undefined
unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)