chore: update merge branch with latest v2
This commit is contained in:
commit
3618ce32a3
16 changed files with 298 additions and 126 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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])) },
|
||||||
|
|
|
||||||
|
|
@ -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")) }),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
24
packages/tui/src/context/log.tsx
Normal file
24
packages/tui/src/context/log.tsx
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue