chore: update merge branch with latest v2

This commit is contained in:
Aiden Cline 2026-07-08 20:40:25 +00:00
commit 3618ce32a3
16 changed files with 298 additions and 126 deletions

View file

@ -18,6 +18,7 @@ export function runTui(
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined let disposeSlots: (() => void) | undefined
return Effect.gen(function* () { return Effect.gen(function* () {
const runFork = Effect.runForkWith(yield* Effect.context())
const options = { baseUrl: transport.url, headers: transport.headers } const options = { baseUrl: transport.url, headers: transport.headers }
const api = OpenCode.make(options) const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
@ -41,6 +42,17 @@ export function runTui(
reload, reload,
args, args,
config, config,
log: (level, message, tags) => {
const effect =
level === "debug"
? Effect.logDebug(message, tags)
: level === "warn"
? Effect.logWarning(message, tags)
: level === "error"
? Effect.logError(message, tags)
: Effect.logInfo(message, tags)
runFork(effect)
},
pluginHost: { pluginHost: {
async start(input) { async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)

View file

@ -119,7 +119,17 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
path: AbsolutePath, path: AbsolutePath,
}) {} }) {}
export type Entry = Document | Directory export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
type: Schema.Literal("agents"),
path: AbsolutePath,
}) {}
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
type: Schema.Literal("claude"),
path: AbsolutePath,
}) {}
export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined { export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries return entries
@ -176,16 +186,37 @@ const layer = Layer.effect(
const discover = Effect.fn("Config.discover")(function* () { const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config) const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered = locationIsGlobal const discovered = locationIsGlobal
? [] ? []
: yield* fs : yield* fs
.up({ .up({
targets: [".opencode", ...names.toReversed()], targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory, start: location.directory,
stop: location.project.directory, stop: location.project.directory,
}) })
.pipe(Effect.orDie) .pipe(Effect.orDie)
// We load certain files from a few other folders in the ecosystem
const claude = [
...((yield* fs.isDir(globalClaudeDirectory))
? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })]
: []),
...discovered
.filter((item) => path.basename(item) === ".claude")
.map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })),
]
const agents = [
...((yield* fs.isDir(globalAgentsDirectory))
? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })]
: []),
...discovered
.filter((item) => path.basename(item) === ".agents")
.map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })),
]
const directories = [ const directories = [
globalDirectory, globalDirectory,
...discovered ...discovered
@ -193,15 +224,18 @@ const layer = Layer.effect(
.toReversed() .toReversed()
.map((directory) => AbsolutePath.make(directory)), .map((directory) => AbsolutePath.make(directory)),
] ]
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() const directPaths = discovered
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe( const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie, Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
) )
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return { return {
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories, directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)],
files: directPaths, files: directPaths,
} }
}) })

View file

@ -51,6 +51,7 @@ export const Plugin = define({
const load = Effect.fn("ConfigAgentPlugin.load")(function* () { const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => { return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry]) if (entry.type === "document") return Effect.succeed([entry])
if (entry.type !== "directory") return Effect.succeed([])
return Effect.gen(function* () { return Effect.gen(function* () {
const files = yield* discover(fs, entry.path) const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) => return yield* Effect.forEach(files, (file) =>

View file

@ -19,6 +19,7 @@ export const Plugin = define({
const load = Effect.fn("ConfigCommandPlugin.load")(function* () { const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => { return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
if (entry.type !== "directory") return Effect.succeed([])
return loadDirectory(fs, entry.path).pipe( return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [ Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },

View file

@ -17,8 +17,18 @@ export const Plugin = define({
const location = yield* Location.Service const location = yield* Location.Service
const loaded = { entries: yield* config.entries() } const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => { yield* ctx.skill.transform((draft) => {
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) {
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const directory of directories) { for (const directory of directories) {
draft.source( draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),

View file

@ -92,6 +92,7 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
}), }),
) )
} }
if (entry.type !== "directory") return Effect.succeed([])
return fs return fs
.glob("{plugin,plugins}/*.{ts,js}", { .glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path, cwd: entry.path,

View file

@ -44,7 +44,7 @@ function testLayer(
) )
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Location.node, locationLayer], [Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })], [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
...(watcher ? ([[Watcher.node, watcher]] as const) : []), ...(watcher ? ([[Watcher.node, watcher]] as const) : []),
]) ])
} }
@ -112,6 +112,7 @@ describe("Config", () => {
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }), info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
}), }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Config.Document({ type: "document", info: new Config.Info({}) }), new Config.Document({ type: "document", info: new Config.Info({}) }),
new Config.Document({ new Config.Document({
type: "document", type: "document",
@ -848,11 +849,19 @@ describe("Config", () => {
const root = path.join(tmp.path, "repo") const root = path.join(tmp.path, "repo")
const parent = path.join(root, "packages") const parent = path.join(root, "packages")
const directory = path.join(parent, "app") const directory = path.join(parent, "app")
const globalAgents = path.join(global, "home", ".agents")
const globalClaude = path.join(global, "home", ".claude")
return Effect.gen(function* () { return Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true }) await fs.mkdir(global, { recursive: true })
await fs.mkdir(globalAgents, { recursive: true })
await fs.mkdir(globalClaude, { recursive: true })
await fs.mkdir(directory, { recursive: true }) await fs.mkdir(directory, { recursive: true })
await fs.mkdir(path.join(root, ".agents"), { recursive: true })
await fs.mkdir(path.join(root, ".claude"), { recursive: true })
await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await Promise.all([ await Promise.all([
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
@ -878,6 +887,16 @@ describe("Config", () => {
AbsolutePath.make(path.join(root, ".opencode")), AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(path.join(directory, ".opencode")), AbsolutePath.make(path.join(directory, ".opencode")),
]) ])
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
])
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([ expect(documents.map((document) => document.info.$schema)).toEqual([
"global", "global",
"root", "root",
@ -887,6 +906,12 @@ describe("Config", () => {
"directory-dot", "directory-dot",
]) ])
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([ expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
"global", "global",
AbsolutePath.make(global), AbsolutePath.make(global),
"root", "root",

View file

@ -46,6 +46,8 @@ describe("ConfigSkillPlugin.Plugin", () => {
Config.Service.of({ Config.Service.of({
entries: () => entries: () =>
Effect.succeed([ Effect.succeed([
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({ new Config.Document({
type: "document", type: "document",
@ -59,6 +61,14 @@ describe("ConfigSkillPlugin.Plugin", () => {
) )
expect(sources).toEqual([ expect(sources).toEqual([
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
}),
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
}),
SkillV2.DirectorySource.make({ SkillV2.DirectorySource.make({
type: "directory", type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),

View file

@ -17,6 +17,7 @@
"./context/epilogue": "./src/context/epilogue.tsx", "./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx", "./context/exit": "./src/context/exit.tsx",
"./context/kv": "./src/context/kv.tsx", "./context/kv": "./src/context/kv.tsx",
"./context/log": "./src/context/log.tsx",
"./context/project": "./src/context/project.tsx", "./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx", "./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx", "./context/sdk": "./src/context/sdk.tsx",

View file

@ -6,6 +6,7 @@ import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ClipboardProvider, useClipboard } from "./context/clipboard" import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { LogProvider, useLog, type LogSink } from "./context/log"
import { ExitProvider, useExit } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue" import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection" import * as Selection from "./util/selection"
@ -149,6 +150,7 @@ export type TuiInput = {
config: TuiConfig.Resolved config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost pluginHost: TuiPluginHost
log?: LogSink
} }
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
@ -184,6 +186,7 @@ function isVersionGreater(left: string, right: string) {
} }
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const log = input.log ?? (() => {})
const global = yield* Global.Service const global = yield* Global.Service
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
const result = yield* Effect.scoped( const result = yield* Effect.scoped(
@ -230,7 +233,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
try { try {
await input.pluginHost.dispose() await input.pluginHost.dispose()
} catch (error) { } catch (error) {
console.error("Failed to dispose TUI plugins", error) log("error", "Failed to dispose TUI plugins", { error })
} }
}), }),
) )
@ -252,112 +255,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
await render(() => { await render(() => {
return ( return (
<ExitProvider <LogProvider log={log}>
exit={(reason) => { <ExitProvider
if (renderer.isDestroyed) return exit={(reason) => {
exit.reason = reason if (renderer.isDestroyed) return
destroyRenderer(renderer) exit.reason = reason
}} destroyRenderer(renderer)
> }}
<EpilogueProvider set={(value) => (exit.epilogue = value)}> >
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}> <EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiPathsProvider <ErrorBoundary
value={{ fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
> >
<TuiTerminalEnvironmentProvider <TuiPathsProvider
value={{ value={{
platform: process.platform, cwd: process.cwd(),
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined, home: global.home,
displayServer: process.env.WAYLAND_DISPLAY state: global.state,
? "wayland" worktree: global.data + "/worktree",
: process.env.DISPLAY
? "x11"
: undefined,
}} }}
> >
<TuiStartupProvider <TuiTerminalEnvironmentProvider
value={{ value={{
initialRoute: process.env.OPENCODE_SCRAP platform: process.platform,
? { type: "plugin", id: "scrap" } multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
: process.env.OPENCODE_ROUTE displayServer: process.env.WAYLAND_DISPLAY
? JSON.parse(process.env.OPENCODE_ROUTE) ? "wayland"
: process.env.DISPLAY
? "x11"
: undefined, : undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}} }}
> >
<ClipboardProvider> <TuiStartupProvider
<OpencodeKeymapProvider keymap={keymap}> value={{
<ArgsProvider {...input.args}> initialRoute: process.env.OPENCODE_SCRAP
<KVProvider> ? { type: "plugin", id: "scrap" }
<ToastProvider> : process.env.OPENCODE_ROUTE
<RouteProvider ? JSON.parse(process.env.OPENCODE_ROUTE)
initialRoute={ : undefined,
input.args.continue skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
? { }}
type: "session", >
sessionID: "dummy", <ClipboardProvider>
} <OpencodeKeymapProvider keymap={keymap}>
: undefined <ArgsProvider {...input.args}>
} <KVProvider>
> <ToastProvider>
<TuiConfigProvider config={input.config}> <RouteProvider
<PluginRuntimeProvider value={pluginRuntime}> initialRoute={
<SDKProvider input.args.continue
client={input.client} ? {
api={input.api} type: "session",
discover={input.discover} sessionID: "dummy",
reload={input.reload} }
> : undefined
<PermissionProvider> }
<ProjectProvider> >
<SyncProvider> <TuiConfigProvider config={input.config}>
<DataProvider> <PluginRuntimeProvider value={pluginRuntime}>
<ThemeProvider mode={mode}> <SDKProvider
<LocalProvider> client={input.client}
<PromptStashProvider> api={input.api}
<DialogProvider> discover={input.discover}
<FrecencyProvider> reload={input.reload}
<PromptHistoryProvider> >
<PromptRefProvider> <PermissionProvider>
<EditorContextProvider> <ProjectProvider>
<LocationProvider> <SyncProvider>
<App <DataProvider>
onSnapshot={input.onSnapshot} <ThemeProvider mode={mode}>
pluginHost={input.pluginHost} <LocalProvider>
/> <PromptStashProvider>
</LocationProvider> <DialogProvider>
</EditorContextProvider> <FrecencyProvider>
</PromptRefProvider> <PromptHistoryProvider>
</PromptHistoryProvider> <PromptRefProvider>
</FrecencyProvider> <EditorContextProvider>
</DialogProvider> <LocationProvider>
</PromptStashProvider> <App
</LocalProvider> onSnapshot={input.onSnapshot}
</ThemeProvider> pluginHost={input.pluginHost}
</DataProvider> />
</SyncProvider> </LocationProvider>
</ProjectProvider> </EditorContextProvider>
</PermissionProvider> </PromptRefProvider>
</SDKProvider> </PromptHistoryProvider>
</PluginRuntimeProvider> </FrecencyProvider>
</TuiConfigProvider> </DialogProvider>
</RouteProvider> </PromptStashProvider>
</ToastProvider> </LocalProvider>
</KVProvider> </ThemeProvider>
</ArgsProvider> </DataProvider>
</OpencodeKeymapProvider> </SyncProvider>
</ClipboardProvider> </ProjectProvider>
</TuiStartupProvider> </PermissionProvider>
</TuiTerminalEnvironmentProvider> </SDKProvider>
</TuiPathsProvider> </PluginRuntimeProvider>
</ErrorBoundary> </TuiConfigProvider>
</EpilogueProvider> </RouteProvider>
</ExitProvider> </ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</EpilogueProvider>
</ExitProvider>
</LogProvider>
) )
}, renderer) }, renderer)
}) })
@ -374,6 +381,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}) })
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) { function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup() const startup = useTuiStartup()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const route = useRoute() const route = useRoute()
@ -454,7 +462,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
dispose: () => attention.dispose(), dispose: () => attention.dispose(),
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to load TUI plugins", error) log.error("Failed to load TUI plugins", { error })
}) })
.finally(() => { .finally(() => {
setReady(true) setReady(true)
@ -816,8 +824,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
// reload resolves once the replacement service is healthy; the // reload resolves once the replacement service is healthy; the
// event stream reattaches through the reconnect loop. // event stream reattaches through the reconnect loop.
await sdk await sdk.reload!()
.reload!()
.then(() => toast.show({ variant: "success", message: "Server reloaded" })) .then(() => toast.show({ variant: "success", message: "Server reloaded" }))
.catch(toast.error) .catch(toast.error)
}, },
@ -1091,7 +1098,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}) })
event.on("installation.update-available", async (evt) => { event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.data.version const version = evt.data.version
const skipped = kv.get("skipped_version") const skipped = kv.get("skipped_version")

View file

@ -0,0 +1,24 @@
import { createContext, useContext, type ParentProps } from "solid-js"
export type LogLevel = "debug" | "info" | "warn" | "error"
export type LogTags = Readonly<Record<string, unknown>>
export type LogSink = (level: LogLevel, message: string, tags: LogTags) => void
const LogContext = createContext<LogSink>()
export function LogProvider(props: ParentProps<{ log: LogSink }>) {
return <LogContext.Provider value={props.log}>{props.children}</LogContext.Provider>
}
export function useLog(tags: LogTags = {}) {
const sink = useContext(LogContext)
if (!sink) throw new Error("Log context must be used within a LogProvider")
const write = (level: LogLevel, message: string, extra: LogTags = {}) => sink(level, message, { ...tags, ...extra })
return {
debug: (message: string, extra?: LogTags) => write("debug", message, extra),
info: (message: string, extra?: LogTags) => write("info", message, extra),
warn: (message: string, extra?: LogTags) => write("warn", message, extra),
error: (message: string, extra?: LogTags) => write("error", message, extra),
}
}

View file

@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js" import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useLog } from "./log"
export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting"
@ -19,6 +20,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Stops and starts the managed service; present only in service mode. // Stops and starts the managed service; present only in service mode.
reload?: () => Promise<void> reload?: () => Promise<void>
}) => { }) => {
const log = useLog()
const abort = new AbortController() const abort = new AbortController()
let client = props.client let client = props.client
let api = props.api let api = props.api
@ -64,7 +66,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
return connection.signal.reason instanceof Error return connection.signal.reason instanceof Error
? connection.signal.reason ? connection.signal.reason
: new Error("Event stream disconnected") : new Error("Event stream disconnected")
if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected") if (first.value.type !== "server.connected")
return new Error("Event stream did not start with server.connected")
clearTimeout(timeout) clearTimeout(timeout)
attempt = 0 attempt = 0
events.emit(first.value.type, first.value) events.emit(first.value.type, first.value)
@ -74,6 +77,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const event = await iterator.next() const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return if (abort.signal.aborted || controller.signal.aborted) return
if (event.done) return new Error("Event stream disconnected") if (event.done) return new Error("Event stream disconnected")
if ("durable" in event.value)
log.info("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value) events.emit(event.value.type, event.value)
} }
})() })()

View file

@ -1 +1,2 @@
export { run, type TuiInput } from "./app" export { run, type TuiInput } from "./app"
export { LogProvider, useLog, type LogLevel, type LogSink, type LogTags } from "./context/log"

View file

@ -34,6 +34,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
api: createApi(calls.fetch), api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: {}, args: {},
log: () => {},
pluginHost: { pluginHost: {
async start() { async start() {
started() started()
@ -115,6 +116,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
api: createApi(calls.fetch), api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: { sessionID: "dummy" }, args: { sessionID: "dummy" },
log: () => {},
pluginHost: { pluginHost: {
async start(input) { async start(input) {
api = input.api api = input.api

View file

@ -9,6 +9,7 @@ import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event" import { useEvent } from "../../../src/context/event"
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk" import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
import type { LogSink } from "../../../src/context/log"
const projectID = "proj_test" const projectID = "proj_test"
@ -49,7 +50,7 @@ function update(version: string): V2Event {
} }
} }
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>, log?: LogSink) {
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const seen: V2Event[] = [] const seen: V2Event[] = []
@ -62,7 +63,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope
}) })
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts log={log}>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}>
<ProjectProvider> <ProjectProvider>
<Probe <Probe
@ -105,6 +106,36 @@ function Probe(props: {
} }
describe("useEvent", () => { describe("useEvent", () => {
test("logs only durable events", async () => {
const logs: Array<{ message: string; tags: Readonly<Record<string, unknown>> }> = []
const { app, emit, seen } = await mount(undefined, (_level, message, tags) => logs.push({ message, tags }))
const durable = event(
{
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { sessionID: "ses_test", title: "Renamed" },
},
{ directory: "/tmp/project" },
)
try {
emit(vcs("main"))
emit(durable)
await wait(() => seen.length === 2 && logs.length === 1)
expect(logs).toEqual([
{
message: "event",
tags: { type: "session.renamed", aggregateID: "ses_test", seq: 1 },
},
])
} finally {
app.renderer.destroy()
}
})
test("delivers events for the current project", async () => { test("delivers events for the current project", async () => {
const { app, emit, seen, workspaces } = await mount() const { app, emit, seen, workspaces } = await mount()

View file

@ -6,27 +6,31 @@ import {
type TuiPaths, type TuiPaths,
} from "../../src/context/runtime" } from "../../src/context/runtime"
import type { ParentProps } from "solid-js" import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
export function TestTuiContexts( export function TestTuiContexts(
props: ParentProps<{ props: ParentProps<{
cwd?: string cwd?: string
directory?: string directory?: string
paths?: Partial<TuiPaths> paths?: Partial<TuiPaths>
log?: LogSink
}>, }>,
) { ) {
return ( return (
<TuiPathsProvider <LogProvider log={props.log ?? (() => {})}>
value={{ <TuiPathsProvider
cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui", value={{
home: "/tmp/opencode/home", cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui",
state: "/tmp/opencode/state", home: "/tmp/opencode/home",
worktree: "/tmp/opencode", state: "/tmp/opencode/state",
...props.paths, worktree: "/tmp/opencode",
}} ...props.paths,
> }}
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}> >
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider> <TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
</TuiTerminalEnvironmentProvider> <TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
</TuiPathsProvider> </TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>
) )
} }