Compare commits

...

15 Commits

Author SHA1 Message Date
Kit Langton 7399d57aee feat(client): generate complete protocol client 2026-06-26 22:54:23 -04:00
Dax Raad 573ab9c24b fix(tui): interrupt v2 sessions with escape 2026-06-26 22:32:21 -04:00
Dax Raad 6a16c41e8f fix(core): continue after tool defects 2026-06-26 22:02:30 -04:00
Dax Raad a491cbee64 fix(tui): hide prompt for running shell commands 2026-06-26 21:35:20 -04:00
Dax Raad 658cbe9caf fix(core): load OpenCode provider config asynchronously 2026-06-26 21:16:15 -04:00
Dax Raad 62b2bc39df test(ci): skip v2 end-to-end checks 2026-06-26 21:04:10 -04:00
Dax Raad beb2c52c3f fix(cli): publish scoped package 2026-06-26 21:01:10 -04:00
Dax Raad 655adbf46e fix: stabilize v2 runtime behavior 2026-06-26 20:56:36 -04:00
Dax Raad ac2a78391f fix(ci): publish v2 as next 2026-06-26 15:55:04 -04:00
Dax Raad 9a9bdaba95 test(ci): drop legacy opencode tests 2026-06-26 14:58:40 -04:00
Dax Raad 8b682c42b6 refactor(tui): use v2 client transport 2026-06-26 14:57:01 -04:00
Dax Raad ad4f1c1018 refactor(ci): use branch npm tag 2026-06-26 14:54:35 -04:00
Dax Raad a0afb63ed0 fix(ci): use v2 npm tag 2026-06-26 14:53:35 -04:00
Dax Raad df9ecb8f6a fix(cli): publish v2 package 2026-06-26 14:52:30 -04:00
Dax Raad e6f660fecf feat(tui): add v2 terminal interface 2026-06-26 14:38:25 -04:00
122 changed files with 8009 additions and 3957 deletions
+7 -2
View File
@@ -6,6 +6,7 @@ on:
branches: branches:
- ci - ci
- dev - dev
- v2
- beta - beta
- fix/npm-native-binary-install - fix/npm-native-binary-install
- snapshot-* - snapshot-*
@@ -31,6 +32,9 @@ permissions:
contents: write contents: write
packages: write packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs: jobs:
version: version:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -122,7 +126,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -221,7 +225,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -447,6 +451,7 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
+2 -5
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- dev - dev
- v2
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -74,13 +75,9 @@ jobs:
working-directory: packages/client working-directory: packages/client
run: bun run check:generated run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+3 -1
View File
@@ -93,7 +93,7 @@
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.11", "version": "1.17.11",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "opencode2": "./bin/opencode2.cjs",
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -316,6 +316,7 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -934,6 +935,7 @@
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+82
View File
@@ -0,0 +1,82 @@
# V2 CLI and TUI development guide
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Interactive debugging
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
@@ -31,11 +31,11 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename)) const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".lildax") const cached = path.join(scriptDir, ".opencode2")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform() const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch() const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "lildax.exe" : "lildax" const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
function supportsAvx2() { function supportsAvx2() {
if (arch !== "x64") return false if (arch !== "x64") return false
@@ -121,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) { if (!resolved) {
console.error( console.error(
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + "It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") + names.map((name) => `"${name}"`).join(" or ") +
" package", " package",
) )
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs" "opencode2": "./bin/opencode2.cjs"
}, },
"files": [ "files": [
"bin" "bin"
+1 -1
View File
@@ -10,7 +10,7 @@ import pkg from "../package.json"
import { modelsData } from "./generate" import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax" const binary = "opencode2"
process.chdir(dir) process.chdir(dir)
await rm("dist", { recursive: true, force: true }) await rm("dist", { recursive: true, force: true })
+7 -6
View File
@@ -25,14 +25,15 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
} }
console.log("binaries", binaries) console.log("binaries", binaries)
const version = Object.values(binaries)[0] const version = Object.values(binaries)[0]
const name = pkg.name
await $`mkdir -p ./dist/${pkg.name}/bin` await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax` await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${pkg.name}/package.json`).write( await Bun.file(`./dist/${name}/package.json`).write(
JSON.stringify( JSON.stringify(
{ {
name: pkg.name, name,
bin: { lildax: "./bin/lildax" }, bin: { opencode2: "./bin/opencode2" },
version, version,
license: pkg.license, license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
@@ -50,4 +51,4 @@ await Promise.all(
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version), publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
), ),
) )
await publish(`./dist/${pkg.name}`, pkg.name, version) await publish(`./dist/${name}`, name, version)
+11
View File
@@ -5,6 +5,16 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface", description: "OpenCode 2.0 preview command line interface",
params: {
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
},
commands: [ commands: [
Spec.make("api", { Spec.make("api", {
description: "Make a request to the running server", description: "Make a request to the running server",
@@ -46,6 +56,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional), port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)), register: Flag.boolean("register").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
}, },
}), }),
], ],
+15 -4
View File
@@ -1,13 +1,24 @@
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Effect } from "effect" import { Effect, Option } from "effect"
import { Daemon } from "../../services/daemon" import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(Commands, () => export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const daemon = yield* Daemon.Service const daemon = yield* Daemon.Service
const transport = yield* daemon.transport() const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const { runTui } = yield* Effect.promise(() => import("../../tui")) const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport) yield* runTui(
transport,
input.standalone
? undefined
: async () => {
await Effect.runPromise(daemon.stop())
return Effect.runPromise(daemon.transport())
},
)
}), }),
) )
+38 -6
View File
@@ -6,6 +6,8 @@ import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http" import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes" import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon" import { Daemon } from "../../services/daemon"
@@ -16,15 +18,41 @@ export default Runtime.handler(
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const daemon = yield* Daemon.Service const daemon = yield* Daemon.Service
const address = yield* listen(input.hostname, input.port, yield* daemon.password()) const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const password = input.stdio ? standalonePassword : yield* daemon.password()
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(input.hostname, input.port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.location.get(undefined, { throwOnError: true }),
)
if (input.register) yield* daemon.register(address) if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`) const url = HttpServer.formatAddress(address)
return yield* Effect.never console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
}), return yield* (input.stdio ? waitForStdinClose() : Effect.never)
}).pipe(Effect.annotateLogs({ role: "server" })),
) )
}), }),
) )
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
function listen(hostname: string, port: Option.Option<number>, password: string) { function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password) if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> => const next = (port: number): ReturnType<typeof bind> =>
@@ -35,11 +63,15 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
} }
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build( return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })), Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(Credential.defaultLayer), Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer), Layer.provide(PermissionSaved.defaultLayer),
), ),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address)) ).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
} }
@@ -8,6 +8,6 @@ export default Runtime.handler(
Commands.commands.service.commands.status, Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () { Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status() const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL) process.stdout.write((url ? url : "stopped") + EOL)
}), }),
) )
+4 -3
View File
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command" import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec" import { Spec } from "./spec"
import { Daemon } from "../services/daemon" import { Daemon } from "../services/daemon"
import { Scope } from "effect"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +11,11 @@ export type Input<Value> =
? Input ? Input
: never : never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service> type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{ type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service> default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
}> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service> type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node> ? Loader<Node>
+12
View File
@@ -2,10 +2,19 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices" import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands" import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime" import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon" import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
const Handlers = Runtime.handlers(Commands, { const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"), $: () => import("./commands/handlers/default"),
@@ -25,8 +34,11 @@ const Handlers = Runtime.handlers(Commands, {
}) })
Runtime.run(Commands, Handlers, { version: "local" }).pipe( Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer), Effect.provide(Daemon.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer), Effect.provide(NodeServices.layer),
Effect.scoped, Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain, NodeRuntime.runMain,
) )
+24 -12
View File
@@ -1,5 +1,5 @@
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth" import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
@@ -28,6 +28,10 @@ const Registration = Schema.Struct({
}) })
type Registration = typeof Registration.Type type Registration = typeof Registration.Type
const Config = Schema.Struct({
password: Schema.optional(Schema.String),
})
function sameRegistration(left: Registration, right: Registration) { function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
} }
@@ -37,22 +41,30 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state const directory = Global.Path.state
const file = path.join(directory, "server.json") const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
const passwordFile = path.join(directory, "password") const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
const password = Effect.fn("cli.daemon.password")(function* (value?: string) { const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined))) const config = yield* fs
if (value === undefined && existing) return existing .readFileString(configFile)
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && config?.password) return config.password
const legacy = yield* fs
.readFileString(legacyPasswordFile)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
// Keep one private credential across server restarts so discovered clients // Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable. // can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url") const temp = configFile + ".tmp"
const temp = passwordFile + ".tmp" yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.makeDirectory(directory, { recursive: true }) yield* fs.rename(temp, configFile)
yield* fs.writeFileString(temp, generated, { mode: 0o600 }) if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
yield* fs.rename(temp, passwordFile) return next
return generated
}) })
const registration = Effect.fnUntraced(function* () { const registration = Effect.fnUntraced(function* () {
@@ -111,7 +123,7 @@ export const layer = Layer.effect(
const existing = yield* healthy().pipe(Effect.option) const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing) const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url if (found?.version === InstallationVersion) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore) if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1] const entrypoint = compiled ? undefined : process.argv[1]
+41
View File
@@ -0,0 +1,41 @@
import { ServerAuth } from "@opencode-ai/server/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password },
extendEnv: true,
// The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run.
stdin: "pipe",
stderr: "ignore",
killSignal: "SIGTERM",
forceKillAfter: "3 seconds",
})
}
export const transport = Effect.fn("cli.standalone.transport")(
function* () {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Standalone from "./standalone"
+38 -28
View File
@@ -2,35 +2,45 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config" import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect" import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { type Transport = { url: string; headers: RequestInit["headers"] }
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({ let disposeSlots: (() => void) | undefined
...transport, return Effect.gen(function* () {
args: {}, const options = { baseUrl: transport.url, headers: transport.headers }
config, const client = createOpencodeClient(options)
fetch: gracefulFetch, const directory = yield* Effect.tryPromise(() =>
pluginHost: { client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
async start() {}, ).pipe(
async dispose() {}, Effect.map((response) => response.data.location.directory),
}, Effect.catch(() =>
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
Effect.map((response) => response.data.directory),
),
),
)
return yield* run({
client: createOpencodeClient({ ...options, directory }),
reload: reload
? async () => {
const next = await reload()
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
}
: undefined,
args: {},
config,
pluginHost: {
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
},
async dispose() {
disposeSlots?.()
},
},
})
}).pipe(Effect.provide(Global.defaultLayer)) }).pipe(Effect.provide(Global.defaultLayer))
} }
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../../src/services/standalone"
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport()
console.log(`${transport.pid} ${transport.url}`)
return yield* Effect.never
}),
),
)
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "bun:test"
import path from "node:path"
test("standalone server exits when its owner is killed", async () => {
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
expect(pid).toBeGreaterThan(0)
expect(url).toStartWith("http://127.0.0.1:")
expect(running(pid)).toBe(true)
owner.kill("SIGKILL")
await owner.exited
expect(await waitForExit(pid)).toBe(true)
} finally {
owner.kill("SIGKILL")
if (running(pid)) process.kill(pid, "SIGKILL")
}
})
async function readLine(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
while (true) {
const result = await reader.read()
if (result.done) break
chunks.push(decoder.decode(result.value, { stream: true }))
const output = chunks.join("")
const newline = output.indexOf("\n")
if (newline !== -1) {
reader.releaseLock()
return output.slice(0, newline)
}
}
reader.releaseLock()
return chunks.join("") + decoder.decode()
}
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
if (!running(pid)) return true
if (attempts === 0) return false
await Bun.sleep(50)
return waitForExit(pid, attempts - 1)
}
function running(pid: number) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. - `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift. The generated surface includes every group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API. The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
+3 -5
View File
@@ -2,19 +2,17 @@ import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { endpointNames, groupNames } from "../src/contract"
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), { const contract = compile(Api, { groupNames, endpointNames })
groupNames: { "server.session": "sessions" },
})
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(
[ [
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))), write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write( write(
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }), emitEffectImported(contract, { module: "../contract", api: "Api" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)), fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
), ),
], ],
+34 -2
View File
@@ -11,9 +11,41 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
{ error: [InvalidRequestError, SessionNotFoundError] }, { error: [InvalidRequestError, SessionNotFoundError] },
) {} ) {}
const Api = makeDefaultApi({ export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware, locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware,
}) })
export const SessionGroup = Api.groups["server.session"] export const groupNames = {
"server.health": "health",
"server.location": "location",
"server.agent": "agents",
"server.session": "sessions",
"server.message": "messages",
"server.model": "models",
"server.provider": "providers",
"server.integration": "integrations",
"server.credential": "credentials",
"server.permission": "permissions",
"server.fs": "files",
"server.command": "commands",
"server.skill": "skills",
"server.event": "events",
"server.pty": "ptys",
"server.question": "questions",
"server.reference": "references",
"server.projectCopy": "projectCopies",
} as const
export const endpointNames = {
"session.messages": "list",
"integration.connect.key": "connectKey",
"integration.connect.oauth": "connectOauth",
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",
"integration.attempt.cancel": "attemptCancel",
"permission.request.list": "listRequests",
"permission.saved.list": "listSaved",
"permission.saved.remove": "removeSaved",
"question.request.list": "listRequests",
} as const
+596 -96
View File
@@ -2,12 +2,10 @@
import { Effect, Stream, Schema } from "effect" import { Effect, Stream, Schema } from "effect"
import { Sse } from "effect/unstable/encoding" import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http" import { HttpClientError } from "effect/unstable/http"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { HttpApiClient } from "effect/unstable/httpapi"
import { SessionGroup } from "../contract" import { Api } from "../contract"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
const Api = HttpApi.make("generated").add(SessionGroup)
type RawClient = HttpApiClient.ForApi<typeof Api> type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) => const mapClientError = <E>(error: E) =>
@@ -15,18 +13,37 @@ const mapClientError = <E>(error: E) =>
? new ClientError({ cause: error }) ? new ClientError({ cause: error })
: error : error
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0] const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
type Endpoint0_0Input = { raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
readonly limit?: Endpoint0_0Request["query"]["limit"] const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
readonly order?: Endpoint0_0Request["query"]["order"]
readonly search?: Endpoint0_0Request["query"]["search"] type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
readonly directory?: Endpoint0_0Request["query"]["directory"] type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
readonly project?: Endpoint0_0Request["query"]["project"] const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
readonly subpath?: Endpoint0_0Request["query"]["subpath"] raw["location.get"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
raw["agent.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint3_0Input = {
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
readonly limit?: Endpoint3_0Request["query"]["limit"]
readonly order?: Endpoint3_0Request["query"]["order"]
readonly search?: Endpoint3_0Request["query"]["search"]
readonly directory?: Endpoint3_0Request["query"]["directory"]
readonly project?: Endpoint3_0Request["query"]["project"]
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
} }
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) => const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) =>
raw["session.list"]({ raw["session.list"]({
query: { query: {
workspace: input?.workspace, workspace: input?.workspace,
@@ -40,14 +57,14 @@ const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0In
}, },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0] type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint0_1Input = { type Endpoint3_1Input = {
readonly id?: Endpoint0_1Request["payload"]["id"] readonly id?: Endpoint3_1Request["payload"]["id"]
readonly agent?: Endpoint0_1Request["payload"]["agent"] readonly agent?: Endpoint3_1Request["payload"]["agent"]
readonly model?: Endpoint0_1Request["payload"]["model"] readonly model?: Endpoint3_1Request["payload"]["model"]
readonly location?: Endpoint0_1Request["payload"]["location"] readonly location?: Endpoint3_1Request["payload"]["location"]
} }
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) => const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) =>
raw["session.create"]({ raw["session.create"]({
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe( }).pipe(
@@ -55,49 +72,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const Endpoint0_2 = (raw: RawClient["server.session"]) => () => const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe( raw["session.active"]({}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] } type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) => const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0] type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_4Input = { type Endpoint3_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"] readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
readonly agent: Endpoint0_4Request["payload"]["agent"] readonly agent: Endpoint3_4Request["payload"]["agent"]
} }
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_5Input = { type Endpoint3_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"] readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
readonly model: Endpoint0_5Request["payload"]["model"] readonly model: Endpoint3_5Request["payload"]["model"]
} }
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_6Input = { type Endpoint3_6Input = {
readonly sessionID: Endpoint0_6Request["params"]["sessionID"] readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly id?: Endpoint0_6Request["payload"]["id"] readonly id?: Endpoint3_6Request["payload"]["id"]
readonly prompt: Endpoint0_6Request["payload"]["prompt"] readonly prompt: Endpoint3_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_6Request["payload"]["delivery"] readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_6Request["payload"]["resume"] readonly resume?: Endpoint3_6Request["payload"]["resume"]
} }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) => const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
@@ -106,23 +123,23 @@ const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] } type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) => const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] } type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_9Input = { type Endpoint3_9Input = {
readonly sessionID: Endpoint0_9Request["params"]["sessionID"] readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
readonly messageID: Endpoint0_9Request["payload"]["messageID"] readonly messageID: Endpoint3_9Request["payload"]["messageID"]
readonly files?: Endpoint0_9Request["payload"]["files"] readonly files?: Endpoint3_9Request["payload"]["files"]
} }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) => const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files }, payload: { messageID: input.messageID, files: input.files },
@@ -131,30 +148,30 @@ const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] } type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) => const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] } type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) => const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] } type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0] type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_13Input = { type Endpoint3_13Input = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"] readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
readonly after?: Endpoint0_13Request["query"]["after"] readonly after?: Endpoint3_13Request["query"]["after"]
} }
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -162,42 +179,525 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
), ),
) )
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_15Input = { type Endpoint3_15Input = {
readonly sessionID: Endpoint0_15Request["params"]["sessionID"] readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly messageID: Endpoint0_15Request["params"]["messageID"] readonly messageID: Endpoint3_15Request["params"]["messageID"]
} }
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const adaptGroup0 = (raw: RawClient["server.session"]) => ({ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw), list: Endpoint3_0(raw),
create: Endpoint0_1(raw), create: Endpoint3_1(raw),
active: Endpoint0_2(raw), active: Endpoint3_2(raw),
get: Endpoint0_3(raw), get: Endpoint3_3(raw),
switchAgent: Endpoint0_4(raw), switchAgent: Endpoint3_4(raw),
switchModel: Endpoint0_5(raw), switchModel: Endpoint3_5(raw),
prompt: Endpoint0_6(raw), prompt: Endpoint3_6(raw),
compact: Endpoint0_7(raw), compact: Endpoint3_7(raw),
wait: Endpoint0_8(raw), wait: Endpoint3_8(raw),
stage: Endpoint0_9(raw), stage: Endpoint3_9(raw),
clear: Endpoint0_10(raw), clear: Endpoint3_10(raw),
commit: Endpoint0_11(raw), commit: Endpoint3_11(raw),
context: Endpoint0_12(raw), context: Endpoint3_12(raw),
events: Endpoint0_13(raw), events: Endpoint3_13(raw),
interrupt: Endpoint0_14(raw), interrupt: Endpoint3_14(raw),
message: Endpoint0_15(raw), message: Endpoint3_15(raw),
}) })
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
type Endpoint4_0Input = {
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
readonly limit?: Endpoint4_0Request["query"]["limit"]
readonly order?: Endpoint4_0Request["query"]["order"]
readonly cursor?: Endpoint4_0Request["query"]["cursor"]
}
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
raw["session.messages"]({
params: { sessionID: input.sessionID },
query: { limit: input.limit, order: input.order, cursor: input.cursor },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
raw["model.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
raw["provider.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint6_1Input = {
readonly providerID: Endpoint6_1Request["params"]["providerID"]
readonly location?: Endpoint6_1Request["query"]["location"]
}
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
raw["provider.get"]({ params: { providerID: input.providerID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
raw["integration.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint7_1Input = {
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
readonly location?: Endpoint7_1Request["query"]["location"]
}
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
raw["integration.get"]({ params: { integrationID: input.integrationID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint7_2Input = {
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
readonly location?: Endpoint7_2Request["query"]["location"]
readonly key: Endpoint7_2Request["payload"]["key"]
readonly label?: Endpoint7_2Request["payload"]["label"]
}
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input.integrationID },
query: { location: input.location },
payload: { key: input.key, label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
type Endpoint7_3Input = {
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
readonly location?: Endpoint7_3Request["query"]["location"]
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
readonly label?: Endpoint7_3Request["payload"]["label"]
}
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
raw["integration.connect.oauth"]({
params: { integrationID: input.integrationID },
query: { location: input.location },
payload: { methodID: input.methodID, inputs: input.inputs, label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint7_4Input = {
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
readonly location?: Endpoint7_4Request["query"]["location"]
}
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input.attemptID },
query: { location: input.location },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint7_5Input = {
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
readonly location?: Endpoint7_5Request["query"]["location"]
readonly code?: Endpoint7_5Request["payload"]["code"]
}
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input.attemptID },
query: { location: input.location },
payload: { code: input.code },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
type Endpoint7_6Input = {
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
readonly location?: Endpoint7_6Request["query"]["location"]
}
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input.attemptID },
query: { location: input.location },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
list: Endpoint7_0(raw),
get: Endpoint7_1(raw),
connectKey: Endpoint7_2(raw),
connectOauth: Endpoint7_3(raw),
attemptStatus: Endpoint7_4(raw),
attemptComplete: Endpoint7_5(raw),
attemptCancel: Endpoint7_6(raw),
})
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint8_0Input = {
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
readonly location?: Endpoint8_0Request["query"]["location"]
readonly label: Endpoint8_0Request["payload"]["label"]
}
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
raw["credential.update"]({
params: { credentialID: input.credentialID },
query: { location: input.location },
payload: { label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint8_1Input = {
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
readonly location?: Endpoint8_1Request["query"]["location"]
}
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
raw["credential.remove"]({ params: { credentialID: input.credentialID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
raw["permission.request.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.projectID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
raw["permission.saved.remove"]({ params: { id: input.id } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint9_3Input = {
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
readonly id?: Endpoint9_3Request["payload"]["id"]
readonly action: Endpoint9_3Request["payload"]["action"]
readonly resources: Endpoint9_3Request["payload"]["resources"]
readonly save?: Endpoint9_3Request["payload"]["save"]
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
readonly source?: Endpoint9_3Request["payload"]["source"]
readonly agent?: Endpoint9_3Request["payload"]["agent"]
}
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input.sessionID },
payload: {
id: input.id,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
agent: input.agent,
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint9_5Input = {
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
readonly requestID: Endpoint9_5Request["params"]["requestID"]
}
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input.sessionID, requestID: input.requestID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint9_6Input = {
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
readonly requestID: Endpoint9_6Request["params"]["requestID"]
readonly reply: Endpoint9_6Request["payload"]["reply"]
readonly message?: Endpoint9_6Request["payload"]["message"]
}
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input.sessionID, requestID: input.requestID },
payload: { reply: input.reply, message: input.message },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint9_0(raw),
listSaved: Endpoint9_1(raw),
removeSaved: Endpoint9_2(raw),
create: Endpoint9_3(raw),
list: Endpoint9_4(raw),
get: Endpoint9_5(raw),
reply: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.read"]>[0]
type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] }
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
raw["fs.read"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_1Input = {
readonly location?: Endpoint10_1Request["query"]["location"]
readonly path?: Endpoint10_1Request["query"]["path"]
}
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_1Input) =>
raw["fs.list"]({ query: { location: input?.location, path: input?.path } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_2Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_2Input = {
readonly location?: Endpoint10_2Request["query"]["location"]
readonly query: Endpoint10_2Request["query"]["query"]
readonly type?: Endpoint10_2Request["query"]["type"]
readonly limit?: Endpoint10_2Request["query"]["limit"]
}
const Endpoint10_2 = (raw: RawClient["server.fs"]) => (input: Endpoint10_2Input) =>
raw["fs.find"]({
query: { location: input.location, query: input.query, type: input.type, limit: input.limit },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({
read: Endpoint10_0(raw),
list: Endpoint10_1(raw),
find: Endpoint10_2(raw),
})
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
raw["command.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
raw["skill.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
raw["event.subscribe"]({}).pipe(Effect.mapError(mapClientError))
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
raw["pty.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint14_1Input = {
readonly location?: Endpoint14_1Request["query"]["location"]
readonly command?: Endpoint14_1Request["payload"]["command"]
readonly args?: Endpoint14_1Request["payload"]["args"]
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
readonly title?: Endpoint14_1Request["payload"]["title"]
readonly env?: Endpoint14_1Request["payload"]["env"]
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
raw["pty.create"]({
query: { location: input?.location },
payload: { command: input?.command, args: input?.args, cwd: input?.cwd, title: input?.title, env: input?.env },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint14_2Input = {
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
readonly location?: Endpoint14_2Request["query"]["location"]
}
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
raw["pty.get"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint14_3Input = {
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
readonly location?: Endpoint14_3Request["query"]["location"]
readonly title?: Endpoint14_3Request["payload"]["title"]
readonly size?: Endpoint14_3Request["payload"]["size"]
}
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
raw["pty.update"]({
params: { ptyID: input.ptyID },
query: { location: input.location },
payload: { title: input.title, size: input.size },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint14_4Input = {
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
readonly location?: Endpoint14_4Request["query"]["location"]
}
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
raw["pty.remove"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_5Request = Parameters<RawClient["server.pty"]["pty.connectToken"]>[0]
type Endpoint14_5Input = {
readonly ptyID: Endpoint14_5Request["params"]["ptyID"]
readonly location?: Endpoint14_5Request["query"]["location"]
}
const Endpoint14_5 = (raw: RawClient["server.pty"]) => (input: Endpoint14_5Input) =>
raw["pty.connectToken"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_6Request = Parameters<RawClient["server.pty"]["pty.connect"]>[0]
type Endpoint14_6Input = { readonly ptyID: Endpoint14_6Request["params"]["ptyID"] }
const Endpoint14_6 = (raw: RawClient["server.pty"]) => (input: Endpoint14_6Input) =>
raw["pty.connect"]({ params: { ptyID: input.ptyID } }).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
list: Endpoint14_0(raw),
create: Endpoint14_1(raw),
get: Endpoint14_2(raw),
update: Endpoint14_3(raw),
remove: Endpoint14_4(raw),
connectToken: Endpoint14_5(raw),
connect: Endpoint14_6(raw),
})
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
raw["question.request.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
raw["session.question.list"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint15_2Input = {
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
readonly requestID: Endpoint15_2Request["params"]["requestID"]
readonly answers: Endpoint15_2Request["payload"]["answers"]
}
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input.sessionID, requestID: input.requestID },
payload: { answers: input.answers },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint15_3Input = {
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
readonly requestID: Endpoint15_3Request["params"]["requestID"]
}
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input.sessionID, requestID: input.requestID } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint15_0(raw),
list: Endpoint15_1(raw),
reply: Endpoint15_2(raw),
reject: Endpoint15_3(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
raw["reference.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint17_0Input = {
readonly projectID: Endpoint17_0Request["params"]["projectID"]
readonly location?: Endpoint17_0Request["query"]["location"]
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
readonly directory: Endpoint17_0Request["payload"]["directory"]
readonly name?: Endpoint17_0Request["payload"]["name"]
}
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input.projectID },
query: { location: input.location },
payload: { strategy: input.strategy, directory: input.directory, name: input.name },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint17_1Input = {
readonly projectID: Endpoint17_1Request["params"]["projectID"]
readonly location?: Endpoint17_1Request["query"]["location"]
readonly directory: Endpoint17_1Request["payload"]["directory"]
readonly force: Endpoint17_1Request["payload"]["force"]
}
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input.projectID },
query: { location: input.location },
payload: { directory: input.directory, force: input.force },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint17_2Input = {
readonly projectID: Endpoint17_2Request["params"]["projectID"]
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
raw["projectCopy.refresh"]({ params: { projectID: input.projectID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint17_0(raw),
remove: Endpoint17_1(raw),
refresh: Endpoint17_2(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
agents: adaptGroup2(raw["server.agent"]),
sessions: adaptGroup3(raw["server.session"]),
messages: adaptGroup4(raw["server.message"]),
models: adaptGroup5(raw["server.model"]),
providers: adaptGroup6(raw["server.provider"]),
integrations: adaptGroup7(raw["server.integration"]),
credentials: adaptGroup8(raw["server.credential"]),
permissions: adaptGroup9(raw["server.permission"]),
files: adaptGroup10(raw["server.fs"]),
commands: adaptGroup11(raw["server.command"]),
skills: adaptGroup12(raw["server.skill"]),
events: adaptGroup13(raw["server.event"]),
ptys: adaptGroup14(raw["server.pty"]),
questions: adaptGroup15(raw["server.question"]),
references: adaptGroup16(raw["server.reference"]),
projectCopies: adaptGroup17(raw["server.projectCopy"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
+724
View File
@@ -1,4 +1,9 @@
import type { import type {
HealthGetOutput,
LocationGetInput,
LocationGetOutput,
AgentsListInput,
AgentsListOutput,
SessionsListInput, SessionsListInput,
SessionsListOutput, SessionsListOutput,
SessionsCreateInput, SessionsCreateInput,
@@ -30,6 +35,87 @@ import type {
SessionsInterruptOutput, SessionsInterruptOutput,
SessionsMessageInput, SessionsMessageInput,
SessionsMessageOutput, SessionsMessageOutput,
MessagesListInput,
MessagesListOutput,
ModelsListInput,
ModelsListOutput,
ProvidersListInput,
ProvidersListOutput,
ProvidersGetInput,
ProvidersGetOutput,
IntegrationsListInput,
IntegrationsListOutput,
IntegrationsGetInput,
IntegrationsGetOutput,
IntegrationsConnectKeyInput,
IntegrationsConnectKeyOutput,
IntegrationsConnectOauthInput,
IntegrationsConnectOauthOutput,
IntegrationsAttemptStatusInput,
IntegrationsAttemptStatusOutput,
IntegrationsAttemptCompleteInput,
IntegrationsAttemptCompleteOutput,
IntegrationsAttemptCancelInput,
IntegrationsAttemptCancelOutput,
CredentialsUpdateInput,
CredentialsUpdateOutput,
CredentialsRemoveInput,
CredentialsRemoveOutput,
PermissionsListRequestsInput,
PermissionsListRequestsOutput,
PermissionsListSavedInput,
PermissionsListSavedOutput,
PermissionsRemoveSavedInput,
PermissionsRemoveSavedOutput,
PermissionsCreateInput,
PermissionsCreateOutput,
PermissionsListInput,
PermissionsListOutput,
PermissionsGetInput,
PermissionsGetOutput,
PermissionsReplyInput,
PermissionsReplyOutput,
FilesReadInput,
FilesReadOutput,
FilesListInput,
FilesListOutput,
FilesFindInput,
FilesFindOutput,
CommandsListInput,
CommandsListOutput,
SkillsListInput,
SkillsListOutput,
EventsSubscribeOutput,
PtysListInput,
PtysListOutput,
PtysCreateInput,
PtysCreateOutput,
PtysGetInput,
PtysGetOutput,
PtysUpdateInput,
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
PtysConnectTokenInput,
PtysConnectTokenOutput,
PtysConnectInput,
PtysConnectOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
QuestionsListOutput,
QuestionsReplyInput,
QuestionsReplyOutput,
QuestionsRejectInput,
QuestionsRejectOutput,
ReferencesListInput,
ReferencesListOutput,
ProjectCopiesCreateInput,
ProjectCopiesCreateOutput,
ProjectCopiesRemoveInput,
ProjectCopiesRemoveOutput,
ProjectCopiesRefreshInput,
ProjectCopiesRefreshOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -53,6 +139,7 @@ interface RequestDescriptor {
readonly successStatus: number readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number> readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean readonly empty: boolean
readonly binary: boolean
} }
export function make(options: ClientOptions) { export function make(options: ClientOptions) {
@@ -104,6 +191,13 @@ export function make(options: ClientOptions) {
} catch {} } catch {}
return undefined as A return undefined as A
} }
if (descriptor.binary) {
try {
return new Uint8Array(await response.arrayBuffer()) as A
} catch (cause) {
throw new ClientError("Transport", { cause })
}
}
return (await json(response)) as A return (await json(response)) as A
} }
@@ -165,6 +259,50 @@ export function make(options: ClientOptions) {
}) })
return { return {
health: {
get: (requestOptions?: RequestOptions) =>
request<HealthGetOutput>(
{
method: "GET",
path: `/api/health`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
request<LocationGetOutput>(
{
method: "GET",
path: `/api/location`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
agents: {
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
request<AgentsListOutput>(
{
method: "GET",
path: `/api/agent`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
sessions: { sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) => list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>( request<SessionsListOutput>(
@@ -184,6 +322,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [400, 401], declaredStatuses: [400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -196,6 +335,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -207,6 +347,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -218,6 +359,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -230,6 +372,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -242,6 +385,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -254,6 +398,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [409, 404, 400, 401], declaredStatuses: [409, 404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -265,6 +410,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 503, 400, 401], declaredStatuses: [404, 503, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -276,6 +422,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 503, 400, 401], declaredStatuses: [404, 503, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -288,6 +435,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -299,6 +447,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -310,6 +459,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -321,6 +471,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -333,6 +484,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -344,6 +496,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -355,10 +508,581 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
}, },
messages: {
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
request<MessagesListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input.limit, order: input.order, cursor: input.cursor },
successStatus: 200,
declaredStatuses: [400, 404, 500, 401],
empty: false,
binary: false,
},
requestOptions,
),
},
models: {
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
request<ModelsListOutput>(
{
method: "GET",
path: `/api/model`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
providers: {
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
request<ProvidersListOutput>(
{
method: "GET",
path: `/api/provider`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
request<ProvidersGetOutput>(
{
method: "GET",
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
integrations: {
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
request<IntegrationsListOutput>(
{
method: "GET",
path: `/api/integration`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
request<IntegrationsGetOutput>(
{
method: "GET",
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input.location },
body: { key: input.key, label: input.label },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input.location },
body: { methodID: input.methodID, inputs: input.inputs, label: input.label },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input.location },
body: { code: input.code },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
},
credentials: {
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialsUpdateOutput>(
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input.location },
body: { label: input.label },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialsRemoveOutput>(
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
},
permissions: {
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionsListRequestsOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListSavedOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.projectID },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionsRemoveSavedOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
body: {
id: input.id,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
agent: input.agent,
},
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
request<PermissionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input.reply, message: input.message },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
},
files: {
read: (input?: FilesReadInput, requestOptions?: RequestOptions) =>
request<FilesReadOutput>(
{
method: "GET",
path: `/api/fs/read/*`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: true,
},
requestOptions,
),
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
request<FilesListOutput>(
{
method: "GET",
path: `/api/fs/list`,
query: { location: input?.location, path: input?.path },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
request<FilesFindOutput>(
{
method: "GET",
path: `/api/fs/find`,
query: { location: input.location, query: input.query, type: input.type, limit: input.limit },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
commands: {
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
request<CommandsListOutput>(
{
method: "GET",
path: `/api/command`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
skills: {
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
request<SkillsListOutput>(
{
method: "GET",
path: `/api/skill`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
events: {
subscribe: (requestOptions?: RequestOptions) =>
request<EventsSubscribeOutput>(
{
method: "GET",
path: `/api/event`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
ptys: {
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
request<PtysListOutput>(
{
method: "GET",
path: `/api/pty`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
request<PtysCreateOutput>(
{
method: "POST",
path: `/api/pty`,
query: { location: input?.location },
body: { command: input?.command, args: input?.args, cwd: input?.cwd, title: input?.title, env: input?.env },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
request<PtysGetOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
request<PtysUpdateOutput>(
{
method: "PUT",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input.location },
body: { title: input.title, size: input.size },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
request<PtysRemoveOutput>(
{
method: "DELETE",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
binary: false,
},
requestOptions,
),
connectToken: (input: PtysConnectTokenInput, requestOptions?: RequestOptions) =>
request<PtysConnectTokenOutput>(
{
method: "POST",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect-token`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
connect: (input: PtysConnectInput, requestOptions?: RequestOptions) =>
request<PtysConnectOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect`,
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
request<QuestionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input.answers },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
request<QuestionsRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
},
references: {
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
request<ReferencesListOutput>(
{
method: "GET",
path: `/api/reference`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
projectCopies: {
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input.location },
body: { strategy: input.strategy, directory: input.directory, name: input.name },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRemoveOutput>(
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input.location },
body: { directory: input.directory, force: input.force },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRefreshOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
},
} }
} }
File diff suppressed because it is too large Load Diff
@@ -19,8 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { HttpApi } from "effect/unstable/httpapi" import { Api as ClientApi, endpointNames, groupNames } from "../src/contract"
import { SessionGroup } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => { test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID) expect(AgentV2.ID).toBe(Agent.ID)
@@ -31,17 +30,16 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt) expect(CorePrompt).toBe(Prompt)
expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier) expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
expect(Session.ID.create()).toStartWith("ses_") expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global") expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic") expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_") expect(Workspace.ID.create()).toStartWith("wrk_")
}) })
test("client and Server Session contracts generate identically", () => { test("client and Server contracts generate identically", () => {
const options = { groupNames: { "server.session": "sessions" } } const server = compile(Api, { groupNames, endpointNames })
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options) const client = compile(ClientApi, { groupNames, endpointNames })
const client = compile(HttpApi.make("client").add(SessionGroup), options)
expect(emitPromise(client)).toEqual(emitPromise(server)) expect(emitPromise(client)).toEqual(emitPromise(server))
}) })
+44
View File
@@ -1,6 +1,50 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { isUnauthorizedError, OpenCode } from "../src" import { isUnauthorizedError, OpenCode } from "../src"
test("exposes every authoritative API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
expect(Object.keys(client)).toEqual([
"health",
"location",
"agents",
"sessions",
"messages",
"models",
"providers",
"integrations",
"credentials",
"permissions",
"files",
"commands",
"skills",
"events",
"ptys",
"questions",
"references",
"projectCopies",
])
expect(Object.keys(client.messages)).toEqual(["list"])
expect(Object.keys(client.integrations)).toEqual([
"list",
"get",
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.permissions)).toEqual([
"listRequests",
"listSaved",
"removeSaved",
"create",
"list",
"get",
"reply",
])
})
test("sessions.get returns the wire projection", async () => { test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
+1
View File
@@ -102,6 +102,7 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
+17 -14
View File
@@ -23,6 +23,7 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { Reference } from "../reference" import { Reference } from "../reference"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { State } from "../state"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent" import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command" import { CommandPlugin } from "./command"
@@ -102,20 +103,22 @@ export const locationLayer = Layer.effectDiscard(
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
} }
yield* Effect.gen(function* () { yield* State.batch(
yield* add(ConfigReferencePlugin.Plugin) Effect.gen(function* () {
yield* add(AgentPlugin.Plugin) yield* add(ConfigReferencePlugin.Plugin)
yield* add(CommandPlugin.Plugin) yield* add(AgentPlugin.Plugin)
yield* add(SkillPlugin.Plugin) yield* add(CommandPlugin.Plugin)
yield* add(ModelsDevPlugin) yield* add(SkillPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin) yield* add(ModelsDevPlugin)
yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigExternalPlugin.Plugin) for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin) yield* add(ConfigExternalPlugin.Plugin)
yield* add(VariantPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) yield* add(VariantPlugin.Plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}), }),
).pipe( ).pipe(
Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(PluginV2.locationLayer),
@@ -1,4 +1,4 @@
import { Duration, Effect, Schema, Stream } from "effect" import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect" import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -79,6 +79,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined let providers: typeof ConfigV1.Info.Type.provider | undefined
@@ -105,7 +106,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
}) })
yield* load() connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
yield* ctx.catalog.transform((catalog) => { yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) { for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
@@ -176,11 +177,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
} }
}) })
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")), Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))), Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh().pipe(Effect.forkScoped)
}), }),
}) })
+19
View File
@@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
} }
export interface RunOptions { export interface RunOptions {
readonly combineOutput?: boolean
readonly maxOutputBytes?: number readonly maxOutputBytes?: number
readonly maxErrorBytes?: number readonly maxErrorBytes?: number
readonly signal?: AbortSignal readonly signal?: AbortSignal
@@ -37,8 +38,10 @@ export interface RunStreamOptions {
export interface RunResult { export interface RunResult {
readonly command: string readonly command: string
readonly exitCode: number readonly exitCode: number
readonly output?: Buffer
readonly stdout: Buffer readonly stdout: Buffer
readonly stderr: Buffer readonly stderr: Buffer
readonly outputTruncated?: boolean
readonly stdoutTruncated: boolean readonly stdoutTruncated: boolean
readonly stderrTruncated: boolean readonly stderrTruncated: boolean
} }
@@ -143,6 +146,22 @@ export const layer = Layer.effect(
const collect = Effect.scoped( const collect = Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* spawner.spawn(command) const handle = yield* spawner.spawn(command)
if (options?.combineOutput) {
const [output, exitCode] = yield* Effect.all(
[collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
{ concurrency: "unbounded" },
)
return {
command: description,
exitCode,
output: output.buffer,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
outputTruncated: output.truncated,
stdoutTruncated: false,
stderrTruncated: false,
} satisfies RunResult
}
const [stdout, stderr, exitCode] = yield* Effect.all( const [stdout, stderr, exitCode] = yield* Effect.all(
[ [
collectStream(handle.stdout, options?.maxOutputBytes), collectStream(handle.stdout, options?.maxOutputBytes),
+5 -1
View File
@@ -1,3 +1,7 @@
export * as PublicEventManifest from "./public-event-manifest" export * as PublicEventManifest from "./public-event-manifest"
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest" import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
+7 -1
View File
@@ -390,7 +390,13 @@ export const layer = Layer.unwrap(
}) })
}), }),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) { switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID) const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, { yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: SessionMessage.ID.create(), messageID: SessionMessage.ID.create(),
@@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID, id: event.data.reasoningID,
text: "", text: "",
providerMetadata: event.data.providerMetadata, providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp },
}), }),
), ),
) )
@@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft, event.data.reasoningID)
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
} }
}) })
+2 -1
View File
@@ -333,7 +333,8 @@ export const layer = Layer.effect(
if (stream._tag === "Success" && !publisher.hasProviderError()) if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
return yield* Effect.failCause(settled.cause)
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
}), }),
) )
+1 -1
View File
@@ -132,7 +132,7 @@ export const fromCatalogModel = (
credential?: Credential.Value, credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => { ): Effect.Effect<Model, UnsupportedApiError> => {
const resolved = const resolved =
credential?.metadata === undefined credential?.type !== "key" || credential.metadata === undefined
? model ? model
: produce(model, (draft) => { : produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata) Object.assign(draft.request.body, credential.metadata)
+45 -11
View File
@@ -1,6 +1,8 @@
export * as ApplyPatchTool from "./apply-patch" export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
@@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
target: Schema.String, target: Schema.String,
}) })
export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) export const Output = Schema.Struct({
applied: Schema.Array(Applied),
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type export type Output = typeof Output.Type
export const toModelOutput = (output: Output) => export const toModelOutput = (output: Output) =>
@@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
].join("\n") ].join("\n")
type Prepared = type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target }) | (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & { | (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target readonly target: LocationMutation.Target
readonly source: Uint8Array readonly source: Uint8Array
readonly content: string readonly content: string
readonly before: string
readonly after: string
}) })
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
@@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
for (const { hunk, target } of targets) { for (const { hunk, target } of targets) {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
if (hunk.type === "add") { if (hunk.type === "add") {
prepared.push({ ...hunk, target }) prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
})
return return
} }
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") { if (hunk.type === "delete") {
prepared.push({ ...hunk, target }) prepared.push({ ...hunk, target, before, after: "" })
return return
} }
const source = yield* fs.readFile(target.canonical) const update = Patch.derive(hunk.path, hunk.chunks, original)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({ prepared.push({
...hunk, ...hunk,
target, target,
source, source,
content: Patch.joinBom(update.content, update.bom), content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
}) })
}).pipe(Effect.mapError(() => fail(hunk.path))) }).pipe(Effect.mapError(() => fail(hunk.path)))
} }
const patchFiles = prepared.map(patchFile)
yield* Effect.forEach( yield* Effect.forEach(
prepared, prepared,
(change) => (change) =>
@@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))), }).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true }, { discard: true },
) )
return { applied } return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
}, },
}), }),
@@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}
+29 -37
View File
@@ -30,16 +30,15 @@ export const Input = Schema.Struct({
}), }),
}) })
const Output = Schema.Struct({ const StructuredOutput = Schema.Struct({
command: Schema.String, exit: Schema.Number.pipe(Schema.optional),
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
output: Schema.String,
truncated: Schema.Boolean, truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional), timeout: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional), })
timedOut: Schema.Boolean.pipe(Schema.optional),
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
warnings: Schema.Array(Schema.String).pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional),
}) })
@@ -47,24 +46,12 @@ type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
const compactOutput = (stdout: string, stderr: string) => {
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
return output || "(no output)"
}
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
return undefined
}
const modelOutput = (output: Output) => { const modelOutput = (output: Output) => {
const warnings = output.warnings?.length const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: "" : ""
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.` if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
} }
const isTimeout = (error: AppProcess.AppProcessError) => const isTimeout = (error: AppProcess.AppProcessError) =>
@@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard(
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Input, input: Input,
output: Output, output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.output },
{ type: "text", text: modelOutput(output) },
],
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {
const source = { const source = {
@@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard(
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess const result = yield* appProcess
.run(command, { .run(command, {
combineOutput: true,
timeout: Duration.millis(timeout), timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES, maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
}) })
.pipe( .pipe(
Effect.catchTag("AppProcessError", (error) => Effect.catchTag("AppProcessError", (error) =>
@@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard(
) )
if (!result) { if (!result) {
return { return {
command: input.command,
cwd: target.canonical,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false, truncated: false,
timedOut: true, timeout: true,
...(warnings.length ? { warnings } : {}), ...(warnings.length ? { warnings } : {}),
} }
} }
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) const output = result.output?.toString("utf8") || "(no output)"
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
return { return {
command: input.command, exit: result.exitCode,
cwd: target.canonical, output: notice ? `${output}\n\n${notice}` : output,
exitCode: result.exitCode, truncated: result.outputTruncated === true,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
...(warnings.length ? { warnings } : {}), ...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}), }),
+22 -6
View File
@@ -7,6 +7,8 @@
export * as EditTool from "./edit" export * as EditTool from "./edit"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
@@ -30,10 +32,7 @@ export const Input = Schema.Struct({
}) })
export const Output = Schema.Struct({ export const Output = Schema.Struct({
operation: Schema.Literal("write"), files: Schema.Array(FileDiff.Info),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
replacements: Schema.Number, replacements: Schema.Number,
}) })
export type Output = typeof Output.Type export type Output = typeof Output.Type
@@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
export const toModelOutput = (output: Output, oldString: string, newString: string) => export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[ [
`Edited file successfully: ${output.resource}`, `Edited file successfully: ${output.files[0]?.file}`,
`Replacements: ${output.replacements}`, `Replacements: ${output.replacements}`,
"```diff", "```diff",
...previewLines(oldString, "-"), ...previewLines(oldString, "-"),
@@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
input.replaceAll === true input.replaceAll === true
? source.text.replaceAll(oldString, newString) ? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString) : source.text.replace(oldString, newString)
const counts = diffLines(source.text, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced) const next = splitBom(replaced)
const result = yield* unableToEdit( const result = yield* unableToEdit(
files.writeIfUnchanged({ files.writeIfUnchanged({
@@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom), content: joinBom(next.text, source.bom || next.bom),
}), }),
) )
return { ...result, replacements } satisfies Output return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
}) })
}, },
}), }),
+26 -8
View File
@@ -37,10 +37,19 @@ export type Content =
| { readonly type: "text"; readonly text: string } | { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = { type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly description: string readonly description: string
readonly input: Input readonly input: Input
readonly output: Output readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly execute: ( readonly execute: (
input: Schema.Schema.Type<Input>, input: Schema.Schema.Type<Input>,
context: Context, context: Context,
@@ -59,10 +68,12 @@ type Runtime = {
const runtimes = new WeakMap<AnyTool, Runtime>() const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>( export function make<
config: Config<Input, Output>, Input extends SchemaType<any>,
): Definition<Input, Output> { Output extends SchemaType<any>,
const tool = Object.freeze({}) as Definition<Input, Output> Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
const definitions = new Map<string, ToolDefinition>() const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, { runtimes.set(tool, {
definition: (name) => { definition: (name) => {
@@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
name, name,
description: config.description, description: config.description,
inputSchema: toJsonSchema(config.input), inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output), outputSchema: toJsonSchema(config.structured ?? config.output),
}) })
definitions.set(name, definition) definitions.set(name, definition)
return definition return definition
@@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
config.execute(input, context).pipe( config.execute(input, context).pipe(
Effect.flatMap((output) => Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe( Schema.encodeEffect(config.output)(output).pipe(
Effect.flatMap((output) => {
if (!config.structured || !config.toStructuredOutput)
return Effect.succeed({ output, structured: output })
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
Effect.map((structured) => ({ output, structured })),
)
}),
Effect.mapError( Effect.mapError(
(error) => (error) =>
new ToolFailure({ new ToolFailure({
@@ -92,8 +110,8 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
), ),
), ),
), ),
Effect.map((output) => ({ Effect.map(({ output, structured }) => ({
structured: output, structured,
content: content:
config.toModelOutput?.({ input, output }).map((part) => config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text" part.type === "text"
@@ -42,6 +42,15 @@ const openai: Lowerer = {
}, },
request(options) { request(options) {
const result = snake(options) const result = snake(options)
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
result.reasoning = {
...(isRecord(result.reasoning) ? result.reasoning : {}),
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
}
delete result.reasoning_effort
delete result.reasoning_summary
}
if (options.textVerbosity !== undefined) { if (options.textVerbosity !== undefined) {
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity } result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
delete result.text_verbosity delete result.text_verbosity
+2 -2
View File
@@ -601,9 +601,9 @@ describe("Config", () => {
models: { models: {
model: { model: {
request: { request: {
body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" }, body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
}, },
variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }], variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
}, },
}, },
}) })
@@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => {
expect( expect(
lowerer.request({ lowerer.request({
reasoningEffort: "high", reasoningEffort: "high",
reasoningSummary: "auto",
reasoning: { encryptedContent: true },
textVerbosity: "low", textVerbosity: "low",
text: { outputFormat: "plain" }, text: { outputFormat: "plain" },
nestedValue: { camelCase: true }, nestedValue: { camelCase: true },
}), }),
).toEqual({ ).toEqual({
reasoning_effort: "high", reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
text: { output_format: "plain", verbosity: "low" }, text: { output_format: "plain", verbosity: "low" },
nested_value: { camel_case: true }, nested_value: { camel_case: true },
}) })
@@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
body: { trace: true }, body: { trace: true },
settings: { resourceName: "resource" }, settings: { resourceName: "resource" },
}) })
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({ expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
reasoning_effort: "high", reasoning: { effort: "high", summary: "auto" },
text: { verbosity: "low" }, text: { verbosity: "low" },
}) })
}) })
@@ -25,6 +25,20 @@ function required<T>(value: T | undefined): T {
return value return value
} }
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) { function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease( return Effect.acquireUseRelease(
Effect.sync(() => { Effect.sync(() => {
@@ -67,11 +81,14 @@ describe("OpencodePlugin", () => {
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.sync(() => { Effect.sync(() => {
const authorization: Array<string | null> = [] const authorization: Array<string | null> = []
const gate = Promise.withResolvers<void>()
return { return {
authorization, authorization,
release: gate.resolve,
server: Bun.serve({ server: Bun.serve({
port: 0, port: 0,
fetch: (request) => { fetch: async (request) => {
await gate.promise
authorization.push(request.headers.get("authorization")) authorization.push(request.headers.get("authorization"))
const origin = new URL(request.url).origin const origin = new URL(request.url).origin
return Response.json({ return Response.json({
@@ -110,7 +127,7 @@ describe("OpencodePlugin", () => {
}), }),
} }
}), }),
({ authorization, server }) => ({ authorization, release, server }) =>
Effect.gen(function* () { Effect.gen(function* () {
const credentials = yield* Credential.Service const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
@@ -128,8 +145,15 @@ describe("OpencodePlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
expect(authorization).toEqual([])
release()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote"))) const provider = required(
yield* eventually(
catalog.provider.get(ProviderV2.ID.make("remote")),
(item) => item?.integrationID === Integration.ID.make("opencode"),
),
)
expect(provider).toMatchObject({ expect(provider).toMatchObject({
name: "Remote", name: "Remote",
integrationID: "opencode", integrationID: "opencode",
@@ -39,6 +39,22 @@ describe("AppProcess", () => {
}), }),
) )
it.effect(
"captures stdout and stderr in emission order",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const script = [
'process.stdout.write("out 1\\n")',
'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
].join(";")
const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
expect(result.stdout.toString("utf8")).toBe("")
expect(result.stderr.toString("utf8")).toBe("")
}),
)
it.effect( it.effect(
"non-zero exit returns RunResult; caller can require success", "non-zero exit returns RunResult; caller can require success",
Effect.gen(function* () { Effect.gen(function* () {
+20 -2
View File
@@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
}), }),
) )
it.effect("persists repeated switches as distinct durable Session events", () => it.effect("ignores a model switch when the selected model is unchanged", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const created = yield* session.create({ location }) const created = yield* session.create({ location })
@@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
expect( expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(3) ).toHaveLength(2)
expect(yield* session.get(created.id)).toMatchObject({ model }) expect(yield* session.get(created.id)).toMatchObject({ model })
}), }),
) )
it.effect("treats an omitted variant as the default variant", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const created = yield* session.create({ location, model })
yield* session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
})
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("rejects a model switch for a missing Session", () => it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect" import { DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
@@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => {
}), }),
) )
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "secret",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { server: "https://console.example", orgID: "org_123" },
}),
)
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("rejects catalog APIs without a native route", () => it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () { Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel( const failure = yield* SessionRunnerModel.fromCatalogModel(
+13 -3
View File
@@ -2565,7 +2565,7 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("propagates unexpected local tool defects operationally", () => it.effect("returns unexpected local tool defects to the model and continues", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@@ -2579,11 +2579,20 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }),
], ],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-after-defect" }),
LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }),
LLMEvent.textEnd({ id: "text-after-defect" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
] ]
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect") yield* session.resume(sessionID)
expect(requests).toHaveLength(1) expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" }, { type: "user", text: "Call defect" },
{ {
@@ -2599,6 +2608,7 @@ describe("SessionRunnerLLM", () => {
}, },
], ],
}, },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
]) ])
}), }),
) )
@@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => {
{ type: "update", resource: "update.txt" }, { type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" }, { type: "delete", resource: "remove.txt" },
], ],
files: [
{
file: "nested/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
{
file: "update.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
{
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 1,
patch: expect.stringContaining("-remove"),
},
],
}) })
expect(assertions).toMatchObject([ expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
+48 -32
View File
@@ -31,8 +31,10 @@ let denyAction: string | undefined
let result: AppProcess.RunResult = { let result: AppProcess.RunResult = {
command: "mock", command: "mock",
exitCode: 0, exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0), stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false, stdoutTruncated: false,
stderrTruncated: false, stderrTruncated: false,
} }
@@ -83,8 +85,10 @@ const reset = () => {
result = { result = {
command: "mock", command: "mock",
exitCode: 0, exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0), stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false, stdoutTruncated: false,
stderrTruncated: false, stderrTruncated: false,
} }
@@ -135,24 +139,33 @@ describe("BashTool", () => {
expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, result: {
type: "content",
value: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
},
output: { output: {
structured: { structured: {
command: "pwd", exit: 0,
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "hello\n",
truncated: false, truncated: false,
}, },
content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }], content: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
}, },
}) })
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }]) expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
expect(runs[0]?.options).toMatchObject({ expect(runs[0]?.options).toMatchObject({
combineOutput: true,
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
}) })
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}), }),
@@ -222,13 +235,17 @@ describe("BashTool", () => {
).pipe( ).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." }) expect(settled.result).toEqual({
expect(settled.output?.structured).toMatchObject({ type: "content",
command: "printf core-bash", value: [
cwd: realpathSync(tmp.path), { type: "text", text: "core-bash" },
exitCode: 0, { type: "text", text: "Command exited with code 0." },
output: "core-bash", ],
}) })
expect(settled.output?.structured).toMatchObject({
exit: 0,
})
expect(settled.output?.structured).not.toHaveProperty("output")
}), }),
), ),
) )
@@ -303,11 +320,13 @@ describe("BashTool", () => {
expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toHaveLength(1) expect(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({ expect(settled.output?.structured).toMatchObject({
warnings: [ truncated: false,
`Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, })
], expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
}) })
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
}), }),
), ),
) )
@@ -324,21 +343,19 @@ describe("BashTool", () => {
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
reset() reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.result).toMatchObject({ expect(settled.output?.content[1]).toMatchObject({
type: "text", type: "text",
value: expect.stringContaining("Command exited with code 7"), text: expect.stringContaining("Command exited with code 7"),
}) })
expect(settled.output?.structured).toMatchObject({ expect(settled.output?.structured).toMatchObject({
command: "false", exit: 7,
cwd: realpathSync(tmp.path),
exitCode: 7,
output: "HEAD full output TAIL",
truncated: false, truncated: false,
}) })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
}), }),
), ),
) )
@@ -352,14 +369,14 @@ describe("BashTool", () => {
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
reset() reset()
result = { ...result, stdoutTruncated: true } result = { ...result, outputTruncated: true }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) expect(settled.output?.structured).toMatchObject({ truncated: true })
expect(settled.result).toMatchObject({ expect(settled.output?.content[0]).toMatchObject({
type: "text", type: "text",
value: expect.stringContaining("stdout capture truncated"), text: expect.stringContaining("output capture truncated"),
}) })
expect(settled.output?.structured).not.toHaveProperty("resource") expect(settled.output?.structured).not.toHaveProperty("resource")
}), }),
@@ -379,13 +396,12 @@ describe("BashTool", () => {
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.result).toMatchObject({ expect(settled.output?.content[1]).toMatchObject({
type: "text", type: "text",
value: expect.stringContaining("Command timed out"), text: expect.stringContaining("Command timed out"),
}) })
expect(settled.output?.structured).toMatchObject({ expect(settled.output?.structured).toMatchObject({
command: "sleep 60", timeout: true,
timedOut: true,
truncated: false, truncated: false,
}) })
}), }),
+9 -4
View File
@@ -125,11 +125,16 @@ describe("EditTool", () => {
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
}) })
expect(settled.output?.structured).toEqual({ expect(settled.output?.structured).toEqual({
operation: "write",
target: yield* Effect.promise(() => fs.realpath(target)),
resource: "hello.txt",
existed: true,
replacements: 1, replacements: 1,
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
}) })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
+34 -14
View File
@@ -75,7 +75,10 @@ const manifestName = ".httpapi-codegen.json"
export function compile<Id extends string, Groups extends HttpApiGroup.Any>( export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
api: HttpApi.HttpApi<Id, Groups>, api: HttpApi.HttpApi<Id, Groups>,
options?: { readonly groupNames?: Readonly<Record<string, string>> }, options?: {
readonly groupNames?: Readonly<Record<string, string>>
readonly endpointNames?: Readonly<Record<string, string>>
},
): Contract { ): Contract {
const endpoints: Array<Endpoint> = [] const endpoints: Array<Endpoint> = []
const portable = new Map<SchemaAST.AST, boolean>() const portable = new Map<SchemaAST.AST, boolean>()
@@ -150,7 +153,7 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
effectPortable, effectPortable,
operation: { operation: {
group: groupName, group: groupName,
name: clientEndpointName(endpoint.name), name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name),
input: inputs.map(({ name, source }) => ({ name, source })), input: inputs.map(({ name, source }) => ({ name, source })),
inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required",
success: isStreamSchema(success.schema) success: isStreamSchema(success.schema)
@@ -245,7 +248,13 @@ export function emitPromise(contract: Contract): Output {
}, },
{ {
path: "client.ts", path: "client.ts",
content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"), content: renderPromiseClient(groups)
.replace("readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary: boolean\n}")
.replace(
"return await json(response) as A",
'if (descriptor.binary) {\n try {\n return new Uint8Array(await response.arrayBuffer()) as A\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n return await json(response) as A',
)
.replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
}, },
{ {
path: "index.ts", path: "index.ts",
@@ -277,13 +286,13 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
} }
} else if ( } else if (
!HttpApiSchema.isNoContent(success.ast) && !HttpApiSchema.isNoContent(success.ast) &&
(resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" !["Json", "Uint8Array"].includes(resolveHttpApiEncoding(success.ast)?._tag ?? "Json")
) { ) {
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
} }
for (const error of endpoint.errors) { for (const error of endpoint.errors) {
if (taggedErrorFields(error) === undefined) { if (declaredErrorFields(error) === undefined) {
throw new GenerationError({ reason: `Promise error must be tagged: ${name}` }) throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` })
} }
if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") { if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") {
throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` })
@@ -422,7 +431,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
groups.flatMap((group) => groups.flatMap((group) =>
group.endpoints.flatMap((endpoint) => group.endpoints.flatMap((endpoint) =>
endpoint.errors.flatMap((schema) => { endpoint.errors.flatMap((schema) => {
const tagged = taggedErrorFields(schema) const tagged = declaredErrorFields(schema)
return tagged === undefined ? [] : [[tagged.tag, tagged] as const] return tagged === undefined ? [] : [[tagged.tag, tagged] as const]
}), }),
), ),
@@ -432,7 +441,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
const fields = error.fields const fields = error.fields
.map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`) .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
.join("; ") .join("; ")
return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}` return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}`
}) })
const operations = groups const operations = groups
.flatMap((group) => .flatMap((group) =>
@@ -505,7 +514,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined), endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined),
), ),
] ]
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}, binary: ${resolveHttpApiEncoding(endpoint.successes[0].ast)?._tag === "Uint8Array"} }`
if (endpoint.operation.success === "stream") { if (endpoint.operation.success === "stream") {
const success = endpoint.successes[0] const success = endpoint.successes[0]
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
@@ -556,9 +565,12 @@ function structuralType(schema: Schema.Top) {
) )
const expand = (type: string, seen = new Set<string>()): string => { const expand = (type: string, seen = new Set<string>()): string => {
for (const [reference, value] of references) { for (const [reference, value] of references) {
if (!type.includes(reference)) continue const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
if (seen.has(reference)) throw new GenerationError({ reason: "Recursive Promise types are not implemented" }) if (!new RegExp(pattern).test(type)) continue
type = type.replaceAll(reference, `(${expand(value, new Set([...seen, reference]))})`) if (seen.has(reference)) {
throw new GenerationError({ reason: `Recursive Promise types are not implemented: ${reference}` })
}
type = type.replace(new RegExp(pattern, "g"), `(${expand(value, new Set([...seen, reference]))})`)
} }
return type return type
} }
@@ -921,18 +933,26 @@ function serializable(value: unknown): boolean {
} }
function taggedErrorFields(schema: Schema.Top) { function taggedErrorFields(schema: Schema.Top) {
const fields = declaredErrorFields(schema)
return fields?.key === "_tag" ? fields : undefined
}
function declaredErrorFields(schema: Schema.Top) {
if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) {
return undefined return undefined
} }
const fields = schema.ast.typeParameters[0] const fields = schema.ast.typeParameters[0]
if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined
const tag = fields.propertySignatures.find((field) => field.name === "_tag")?.type const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name
if (key !== "_tag" && key !== "name") return undefined
const tag = fields.propertySignatures.find((field) => field.name === key)?.type
if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined
return { return {
key,
tag: tag.literal, tag: tag.literal,
identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal,
fields: fields.propertySignatures.flatMap((field) => fields: fields.propertySignatures.flatMap((field) =>
field.name === "_tag" || typeof field.name !== "string" field.name === key || typeof field.name !== "string"
? [] ? []
: [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const],
), ),
@@ -151,6 +151,19 @@ describe("HttpApiCodegen.generate", () => {
expect(effect).toContain('raw["session.get"]') expect(effect).toContain('raw["session.get"]')
}) })
test("supports explicit public endpoint names", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.permission")
.add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String }))
.add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })),
)
const contract = compileContract(source, {
endpointNames: { "permission.request.list": "listRequests" },
})
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"])
})
test("preserves optional keys in Promise error types", () => { test("preserves optional keys in Promise error types", () => {
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()( class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
"OptionalError", "OptionalError",
@@ -166,6 +179,22 @@ describe("HttpApiCodegen.generate", () => {
) )
}) })
test("supports name-discriminated Promise errors", () => {
class NamedError extends Schema.ErrorClass<NamedError>("NamedError")(
{ name: Schema.Literal("NamedError"), message: Schema.String },
{ httpApiStatus: 400 },
) {}
const output = emitPromise(
compileContract(
api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('readonly "name": "NamedError"')
expect(types).toContain('"name" in value && value["name"] === "NamedError"')
})
test("erases brands from Promise wire types", () => { test("erases brands from Promise wire types", () => {
const output = emitPromise( const output = emitPromise(
compileContract( compileContract(
@@ -200,6 +229,26 @@ describe("HttpApiCodegen.generate", () => {
) )
}) })
test("expands Promise references only at identifier boundaries", () => {
const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
identifier: "Session",
})
const SessionID = Schema.String.annotate({ identifier: "SessionID" })
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Struct({ session: Session, sessionID: SessionID }),
}),
),
),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
)
})
test("emits Effect Json schemas as standalone Promise types", () => { test("emits Effect Json schemas as standalone Promise types", () => {
const output = emitPromise( const output = emitPromise(
compileContract( compileContract(
@@ -260,6 +309,32 @@ describe("HttpApiCodegen.generate", () => {
).toThrow("Unsupported Promise stream: session.events") ).toThrow("Unsupported Promise stream: session.events")
}) })
test("executes an emitted binary Promise response", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("read", "/file", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(new Uint8Array([1, 2, 3])),
})
expect(await client.session.read()).toEqual(new Uint8Array([1, 2, 3]))
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("executes an emitted Promise GET through fetch", async () => { test("executes an emitted Promise GET through fetch", async () => {
const output = emitPromise( const output = emitPromise(
compileContract( compileContract(
+2 -3
View File
@@ -3,6 +3,7 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@@ -132,7 +133,7 @@ export const AttachCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise( await Effect.runPromise(
run({ run({
url: args.url, client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
config, config,
pluginHost: createLegacyTuiPluginHost(), pluginHost: createLegacyTuiPluginHost(),
args: { args: {
@@ -140,8 +141,6 @@ export const AttachCommand = cmd({
sessionID: args.session, sessionID: args.session,
fork: args.fork, fork: args.fork,
}, },
directory,
headers,
}), }),
) )
}, },
@@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
() => ( () => (
<box width="100%" height={1}> <box width="100%" height={1}>
<text wrapMode="none" truncate> <text wrapMode="none" truncate>
<span style={{ fg: input.theme.block.highlight }}> </span>
<span style={{ fg: input.theme.block.text }}>{input.agent}</span> <span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}> <span style={{ fg: input.theme.block.muted }}>
{" "} {" "}
@@ -10,7 +10,7 @@ export function turnSummaryCommit(input: {
}): StreamCommit { }): StreamCommit {
return { return {
kind: "system", kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`, text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final", phase: "final",
source: "system", source: "system",
summary: { summary: {
+4 -57
View File
@@ -8,8 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
@@ -18,36 +17,6 @@ declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
} }
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
},
}
}
async function target() { async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url) const dist = new URL("./cli/tui/worker.js", import.meta.url)
@@ -211,32 +180,13 @@ export const TuiThreadCommand = cmd({
const config = await TuiConfig.get() const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args) const network = resolveNetworkOptionsNoConfig(args)
const external = const url = (await client.call("server", network)).url
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
}
try { try {
await validateSession({ await validateSession({
url: transport.url, url,
sessionID: args.session, sessionID: args.session,
directory: cwd, directory: cwd,
fetch: transport.fetch,
}) })
} catch (error) { } catch (error) {
UI.error(errorMessage(error)) UI.error(errorMessage(error))
@@ -254,7 +204,7 @@ export const TuiThreadCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise( await Effect.runPromise(
run({ run({
url: transport.url, client: createOpencodeClient({ baseUrl: url, directory: cwd }),
async onSnapshot() { async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot") const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined) const server = await client.call("snapshot", undefined)
@@ -262,9 +212,6 @@ export const TuiThreadCommand = cmd({
}, },
config, config,
pluginHost: createLegacyTuiPluginHost(), pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: { args: {
continue: args.continue, continue: args.continue,
sessionID: args.session, sessionID: args.session,
-26
View File
@@ -3,8 +3,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
@@ -20,33 +18,9 @@ const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection) process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException) process.on("uncaughtException", onUncaughtException)
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = { export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers,
body: input.body,
})
const response = await Server.Default().app.fetch(request)
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
},
snapshot() { snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot") const result = writeHeapSnapshot("server.heapsnapshot")
return result return result
+2 -5
View File
@@ -1,10 +1,7 @@
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = BuiltinTuiPlugin export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] { export function internalTuiPlugins(): InternalTuiPlugin[] {
return createBuiltinPlugins({ return createBuiltinPlugins()
experimentalEventSystem: flags.experimentalEventSystem,
})
} }
+1 -1
View File
@@ -1089,7 +1089,7 @@ async function load(input: {
if (Flag.OPENCODE_PURE && pluginOrigins.length) { if (Flag.OPENCODE_PURE && pluginOrigins.length) {
} }
for (const item of internalTuiPlugins(flags)) { for (const item of internalTuiPlugins()) {
const entry = loadInternalPlugin(item) const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, { addPluginEntry(next, {
@@ -119,7 +119,7 @@ test("turn summary starts at the left edge", async () => {
const commits = claim(out.renderer) const commits = claim(out.renderer)
try { try {
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s") expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
} finally { } finally {
destroy(commits) destroy(commits)
} }
@@ -279,7 +279,7 @@ describe("run session replay", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
kind: "system", kind: "system",
text: "Build · gpt-5 · 2.8s", text: "Build · gpt-5 · 2.8s",
phase: "final", phase: "final",
source: "system", source: "system",
messageID: "msg-1", messageID: "msg-1",
@@ -314,7 +314,7 @@ describe("run session replay", () => {
expect(out.commits.at(-1)).toEqual( expect(out.commits.at(-1)).toEqual(
expect.objectContaining({ expect.objectContaining({
kind: "system", kind: "system",
text: "Build · Little Frank · 2.8s", text: "Build · Little Frank · 2.8s",
summary: { summary: {
agent: "Build", agent: "Build",
model: "Little Frank", model: "Little Frank",
@@ -346,7 +346,7 @@ describe("run session replay", () => {
expect(out.commits.filter((commit) => commit.summary)).toEqual([ expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({ expect.objectContaining({
kind: "system", kind: "system",
text: "Build · gpt-5 · 2.0s", text: "Build · gpt-5 · 2.0s",
messageID: "msg-step-2", messageID: "msg-step-2",
}), }),
]) ])
-82
View File
@@ -1,82 +0,0 @@
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/opencode`
export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
switch (url.pathname) {
case "/agent":
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
case "/session/status":
return json({})
case "/config/providers":
return json({ providers: {}, default: {} })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree, directory })
case "/project/current":
return json({ id: "proj_test" })
case "/provider":
return json({ all: [], default: {}, connected: [] })
case "/session":
return json([])
case "/vcs":
return json({ branch: "main" })
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
return { fetch, session }
}
+5 -2
View File
@@ -1,7 +1,7 @@
import type { import type {
AgentPart, AgentPart,
OpencodeClient, OpencodeClient,
Event, V2Event,
FilePart, FilePart,
LspStatus, LspStatus,
McpStatus, McpStatus,
@@ -517,7 +517,10 @@ export type TuiSlots = {
} }
export type TuiEventBus = { export type TuiEventBus = {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void on: <Type extends V2Event["type"]>(
type: Type,
handler: (event: Extract<V2Event, { type: Type }>) => void,
) => () => void
} }
export type TuiDispose = () => void | Promise<void> export type TuiDispose = () => void | Promise<void>
+4
View File
@@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({
id: Schema.String, id: Schema.String,
text: Schema.String, text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional), providerMetadata: ProviderMetadata.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) }).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+4
View File
@@ -3926,6 +3926,10 @@ export type SessionMessageAssistantReasoning = {
id: string id: string
text: string text: string
providerMetadata?: LlmProviderMetadata providerMetadata?: LlmProviderMetadata
time?: {
created: number
completed?: number
}
} }
export type SessionMessageToolStatePending = { export type SessionMessageToolStatePending = {
+11 -2
View File
@@ -1,16 +1,25 @@
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Effect, Stream } from "effect" import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
import { Effect, Schema, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { Api } from "../api" import { Api } from "../api"
function eventData(data: unknown): Sse.Event { function eventData(data: unknown): Sse.Event {
const event = data as EventV2.Payload
const definition = PublicEventManifest.Latest.get(event.type)
const encoded = definition
? {
...event,
data: Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
}
: event
return { return {
_tag: "Event", _tag: "Event",
event: "message", event: "message",
id: undefined, id: undefined,
data: JSON.stringify(data), data: JSON.stringify(encoded),
} }
} }
@@ -969,7 +969,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
<div data-component="context-tool-group-trigger"> <div data-component="context-tool-group-trigger">
<span <span
data-slot="context-tool-group-title" data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong" class="min-w-0 flex items-center gap-2 text-14-medium"
classList={{
"text-text-strong": pending(),
"text-text-weak": !pending(),
}}
> >
<span data-slot="context-tool-group-label" class="shrink-0"> <span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle <ToolStatusTitle
@@ -981,7 +985,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
</span> </span>
<span <span
data-slot="context-tool-group-summary" data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base" class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal"
classList={{
"text-text-base": pending(),
"text-text-weak": !pending(),
}}
> >
<AnimatedCountList <AnimatedCountList
items={[ items={[
+1
View File
@@ -54,6 +54,7 @@
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
+59 -44
View File
@@ -21,11 +21,10 @@ import {
onCleanup, onCleanup,
batch, batch,
Show, Show,
on,
} from "solid-js" } from "solid-js"
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider, useDialog } from "./ui/dialog"
import { DialogProvider as DialogProviderList } from "./component/dialog-provider" import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component" import { ErrorComponent } from "./component/error-component"
import { PluginRouteMissing } from "./component/plugin-route-missing" import { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project" import { ProjectProvider, useProject } from "./context/project"
@@ -33,8 +32,9 @@ import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event" import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk" import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading" import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { SyncProvider, useSync } from "./context/sync" import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data" import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location" import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local" import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model" import { DialogModel } from "./component/dialog-model"
@@ -76,7 +76,7 @@ import {
useOpencodeKeymap, useOpencodeKeymap,
} from "./keymap" } from "./keymap"
import type { EventSource } from "./context/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention" import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio" import * as TuiAudio from "./audio"
@@ -134,14 +134,11 @@ const appBindingCommands = [
] as const ] as const
export type TuiInput = { export type TuiInput = {
url: string client: OpencodeClient
reload?: () => Promise<OpencodeClient>
args: Args args: Args
config: TuiConfig.Resolved config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost pluginHost: TuiPluginHost
} }
@@ -268,7 +265,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
> >
<TuiStartupProvider <TuiStartupProvider
value={{ value={{
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined, initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT), skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}} }}
> >
@@ -289,13 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
> >
<TuiConfigProvider config={input.config}> <TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider <SDKProvider client={input.client} reload={input.reload}>
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider> <ProjectProvider>
<SyncProvider> <SyncProvider>
<DataProvider> <DataProvider>
@@ -366,10 +361,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const keymap = useOpencodeKeymap() const keymap = useOpencodeKeymap()
const event = useEvent() const event = useEvent()
const sdk = useSDK() const sdk = useSDK()
const reload = sdk.reload
const toast = useToast() const toast = useToast()
const themeState = useTheme() const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync() const sync = useSync()
const data = useData()
const project = useProject() const project = useProject()
const exit = useExit() const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
@@ -389,6 +386,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event, event,
sdk, sdk,
sync, sync,
data,
theme: themeState, theme: themeState,
toast, toast,
renderer, renderer,
@@ -494,9 +492,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
createEffect(() => { createEffect(() => {
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial" // When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
if (continued || sync.status === "loading" || !args.continue) return if (continued || sync.status === "loading" || !args.continue) return
const match = sync.data.session const match = data.session.list().find((session) => !session.parentID)?.id
.toSorted((a, b) => b.time.updated - a.time.updated)
.find((x) => x.parentID === undefined)?.id
if (match) { if (match) {
continued = true continued = true
if (args.fork) { if (args.fork) {
@@ -529,17 +525,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}) })
}) })
createEffect(
on(
() => sync.status === "complete" && sync.data.provider.length === 0,
(isEmpty, wasEmpty) => {
// only trigger when we transition into an empty-provider state
if (!isEmpty || wasEmpty) return
dialog.replace(() => <DialogProviderList />)
},
),
)
const connected = useConnected() const connected = useConnected()
const currentWorktreeWorkspace = createMemo(() => { const currentWorktreeWorkspace = createMemo(() => {
const workspaceID = project.workspace.current() const workspaceID = project.workspace.current()
@@ -563,7 +548,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
name: "session.list", name: "session.list",
title: "Switch session", title: "Switch session",
category: "Session", category: "Session",
suggested: sync.data.session.length > 0, suggested: data.session.list().length > 0,
slashName: "sessions", slashName: "sessions",
slashAliases: ["resume", "continue"], slashAliases: ["resume", "continue"],
run: () => { run: () => {
@@ -729,13 +714,13 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}, },
{ {
name: "provider.connect", name: "provider.connect",
title: "Connect provider", title: "Connect integration",
suggested: !connected(), suggested: !connected(),
slashName: "connect", slashName: "connect",
run: () => { run: () => {
dialog.replace(() => <DialogProviderList />) dialog.replace(() => <DialogIntegration />)
}, },
category: "Provider", category: "Integration",
}, },
...(sync.data.console_state.switchableOrgCount > 1 ...(sync.data.console_state.switchableOrgCount > 1
? [ ? [
@@ -761,6 +746,33 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}, },
category: "System", category: "System",
}, },
...(reload
? [
{
name: "server.reload",
title: "Reload server",
slashName: "reload",
slashAliases: ["restart"],
run: async () => {
dialog.clear()
toast.show({
variant: "info",
message: "Reloading server...",
duration: 30000,
})
await reload()
.then(() =>
toast.show({
variant: "success",
message: "Server reloaded",
}),
)
.catch(toast.error)
},
category: "System",
},
]
: []),
{ {
name: "theme.switch", name: "theme.switch",
title: "Switch theme", title: "Switch theme",
@@ -957,16 +969,16 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event.on("tui.command.execute", (evt, { workspace }) => { event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== project.workspace.current()) return
keymap.dispatchCommand(evt.properties.command) keymap.dispatchCommand(evt.data.command)
}) })
event.on("tui.toast.show", (evt, { workspace }) => { event.on("tui.toast.show", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== project.workspace.current()) return
toast.show({ toast.show({
title: evt.properties.title, title: evt.data.title,
message: evt.properties.message, message: evt.data.message,
variant: evt.properties.variant, variant: evt.data.variant,
duration: evt.properties.duration, duration: evt.data.duration,
}) })
}) })
@@ -974,12 +986,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
if (workspace !== project.workspace.current()) return if (workspace !== project.workspace.current()) return
route.navigate({ route.navigate({
type: "session", type: "session",
sessionID: evt.properties.sessionID, sessionID: evt.data.sessionID,
}) })
}) })
event.on("session.deleted", (evt) => { event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { if (route.data.type === "session" && route.data.sessionID === evt.data.info.id) {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
toast.show({ toast.show({
variant: "info", variant: "info",
@@ -990,7 +1002,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event.on("session.error", (evt, { workspace }) => { event.on("session.error", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== project.workspace.current()) return
const error = evt.properties.error const error = evt.data.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error) const message = errorMessage(error)
@@ -1003,7 +1015,7 @@ 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) console.log("installation.update-available", evt)
const version = evt.properties.version const version = evt.data.version
const skipped = kv.get("skipped_version") const skipped = kv.get("skipped_version")
if (skipped && !isVersionGreater(version, skipped)) return if (skipped && !isVersionGreater(version, skipped)) return
@@ -1102,6 +1114,9 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
<Show when={!startup.skipInitialLoading}> <Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} /> <StartupLoading ready={ready} />
</Show> </Show>
<Show when={sdk.connection.status() === "reconnecting"}>
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
</Show>
</box> </box>
) )
} }
+4 -4
View File
@@ -10,9 +10,9 @@ export function DialogAgent() {
const options = createMemo(() => const options = createMemo(() =>
local.agent.list().map((item) => { local.agent.list().map((item) => {
return { return {
value: item.name, value: item.id,
title: item.name, title: item.id,
description: item.native ? "native" : item.description, description: item.description,
} }
}), }),
) )
@@ -20,7 +20,7 @@ export function DialogAgent() {
return ( return (
<DialogSelect <DialogSelect
title="Select agent" title="Select agent"
current={local.agent.current()?.name} current={local.agent.current()?.id}
options={options()} options={options()}
onSelect={(option) => { onSelect={(option) => {
local.agent.set(option.value) local.agent.set(option.value)
@@ -0,0 +1,463 @@
import { TextAttributes } from "@opentui/core"
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { Link } from "../ui/link"
import { useToast } from "../ui/toast"
const INTEGRATION_PRIORITY: Record<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
}
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted(
(a, b) =>
(INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
}
export function connectMethods(integration: IntegrationInfo): ConnectMethod[] {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export function credentialConnections(integration: IntegrationInfo) {
return integration.connections.filter(
(connection): connection is Extract<ConnectionInfo, { type: "credential" }> => connection.type === "credential",
)
}
export function connectionSummary(integration: IntegrationInfo) {
return integration.connections
.map((connection) => (connection.type === "credential" ? connection.label : `$${connection.name}`))
.join(", ")
}
export function DialogIntegration() {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
const methods = connectMethods(integration)
const connected = integration.connections.length > 0
return {
title: integration.name,
value: integration.id,
description: methods.length ? undefined : "Environment only",
footer: connectionSummary(integration) || undefined,
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog)
: selectMethod(integration, methods, dialog),
}
}),
)
return (
<DialogSelect
title="Connect a service"
options={options()}
emptyView={<text fg={theme.textMuted}>No integrations available</text>}
/>
)
}
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
dialog.replace(() => {
const data = useData()
const sdk = useSDK()
const toast = useToast()
return (
<DialogSelect
title={integration.name}
options={[
...(methods.length
? [
{
title: "Add connection",
value: "add",
onSelect: () => selectMethod(integration, methods, dialog),
},
]
: []),
...credentialConnections(integration).map((connection) => ({
title: `Disconnect ${connection.label}`,
value: connection.id,
onSelect: () => {
void sdk.client.v2.credential
.remove(
{ credentialID: connection.id, location: location(data) },
{ throwOnError: true },
)
.then(() => disconnected(integration.name, data, dialog, toast))
.catch(toast.error)
},
})),
]}
/>
)
})
}
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
onSelect: () => openMethod(integration, method, dialog),
}))}
/>
))
}
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
dialog: ReturnType<typeof useDialog>,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} />)
return
}
void beginOAuth(integration, method, dialog)
}
function KeyMethod(props: { integration: IntegrationInfo; method: Extract<ConnectMethod, { type: "key" }> }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
title={props.method.label ?? `Connect ${props.integration.name}`}
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.client.v2.integration.connect
.key(
{
integrationID: props.integration.id,
location: location(data),
key,
},
{ throwOnError: true },
)
.then(() => connected(props.integration.name, data, dialog, toast))
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
)}
/>
)
}
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
dialog: ReturnType<typeof useDialog>,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => <OAuthStarting integration={integration} method={method} inputs={inputs} />)
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
inputs: Record<string, string>
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
onMount(() => {
void sdk.client.v2.integration.connect
.oauth(
{
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
inputs: props.inputs,
},
{ throwOnError: true },
)
.then((result) => {
if (result.data.data.mode === "code") {
dialog.replace(() => (
<OAuthCode integration={props.integration} title={props.method.label} attempt={result.data.data} />
))
return
}
dialog.replace(() => (
<OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data.data} />
))
})
.catch((cause) => {
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
})
return <OAuthView title={props.method.label} message="Starting authorization..." />
}
function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const clipboard = useClipboard()
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
useBindings(() => ({
bindings: [
{
key: "c",
desc: "Copy authorization details",
group: "Dialog",
cmd: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
const poll = () => {
void sdk.client.v2.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true })
.then((result) => {
const status = result.data.data
if (status.status === "pending") {
timer = setTimeout(poll, 500)
return
}
settled = true
if (status.status === "complete") {
void connected(props.integration.name, data, dialog, toast)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
dialog.clear()
})
.catch((cause) => {
settled = true
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
}
onMount(poll)
onCleanup(() => {
if (timer) clearTimeout(timer)
if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
<OAuthView
title={props.title}
url={props.attempt.url}
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
/>
)
}
function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
let settled = false
onCleanup(() => {
if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.client.v2.integration.attempt
.complete(
{ attemptID: props.attempt.attemptID, location: location(data), code },
{ throwOnError: true },
)
.then(() => {
settled = true
return connected(props.integration.name, data, dialog, toast)
})
.catch((cause) => setError(message(cause)))
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.primary} />
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
</box>
)}
/>
)
}
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
}) {
const dialog = useDialog()
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={theme.primary} />
<Show when={props.instructions}>{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}</Show>
</box>
)}
</Show>
<text fg={theme.textMuted}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</Show>
</box>
)
}
async function promptInputs(
dialog: ReturnType<typeof useDialog>,
prompts: NonNullable<IntegrationOAuthMethod["prompts"]>,
) {
const inputs: Record<string, string> = {}
for (const prompt of prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.type === "select") {
const value = await new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((option) => ({
title: option.label,
value: option.value,
description: option.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
continue
}
const value = await new Promise<string | null>((resolve) => {
dialog.replace(
() => <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={resolve} />,
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}
async function connected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
toast.show({ variant: "success", message: `Connected ${name}` })
dialog.clear()
}
async function disconnected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
toast.show({ variant: "success", message: `Disconnected ${name}` })
dialog.clear()
}
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
}
function message(cause: unknown) {
if (cause instanceof Error) return cause.message
return "Authentication failed"
}
+56 -84
View File
@@ -1,22 +1,23 @@
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" import { sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogIntegration } from "./dialog-integration"
import { DialogVariant } from "./dialog-variant" import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useSync } from "../context/sync" import { useData } from "../context/data"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const sync = useSync() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const connected = useConnected() const connected = useConnected()
const providers = createDialogProviderOptions() const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID) const showExtra = createMemo(() => connected() && !props.providerID)
@@ -29,21 +30,20 @@ export function DialogModel(props: { providerID?: string }) {
function toOptions(items: typeof favorites, category: string) { function toOptions(items: typeof favorites, category: string) {
if (!showSections) return [] if (!showSections) return []
return items.flatMap((item) => { return items.flatMap((item) => {
const provider = sync.data.provider.find((provider) => provider.id === item.providerID) const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return [] if (!model) return []
const provider = providers().get(model.providerID)
return [ return [
{ {
key: item, key: item,
value: { providerID: provider.id, modelID: model.id }, value: { providerID: model.providerID, modelID: model.id },
title: model.name ?? item.modelID, title: model.name,
description: provider.name, releaseDate: model.time.released,
description: provider?.name ?? model.providerID,
category, category,
disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: free(model) ? "Free" : undefined,
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => { onSelect: () => {
onSelect(provider.id, model.id) onSelect(model.providerID, model.id)
}, },
}, },
] ]
@@ -58,80 +58,48 @@ export function DialogModel(props: { providerID?: string }) {
"Recent", "Recent",
) )
const providerOptions = pipe( const modelOptions = sortModelOptions(
sync.data.provider, models()
sortBy( .filter((model) => model.status !== "deprecated")
(provider) => provider.id !== "opencode", .filter((model) => (props.providerID ? model.providerID === props.providerID : true))
(provider) => provider.name, .map((model) => ({
), value: { providerID: model.providerID, modelID: model.id },
flatMap((provider) => title: model.name,
pipe( releaseDate: model.time.released,
provider.models, description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
entries(), ? "(Favorite)"
filter(([_, info]) => info.status !== "deprecated"), : undefined,
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined,
map(([model, info]) => ({ footer: free(model) ? "Free" : undefined,
value: { providerID: provider.id, modelID: model }, onSelect() {
title: info.name ?? model, onSelect(model.providerID, model.id)
releaseDate: info.release_date, },
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) }))
? "(Favorite)" .filter((option) => {
: undefined, if (!showSections) return true
category: connected() ? provider.name : undefined, if (
disabled: provider.id === "opencode" && model.includes("-nano"), favorites.some(
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
onSelect() {
onSelect(provider.id, model)
},
})),
filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
) )
return false )
if ( return false
recents.some( if (
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
) )
) return false
return false return true
return true }),
}), props.providerID !== undefined,
(options) => sortModelOptions(options, props.providerID !== undefined),
),
),
) )
const popularProviders = !connected()
? pipe(
providers(),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []
if (needle) { if (needle) {
return [ return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
...sortModelOptions(
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
false,
),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
} }
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] return [...favoriteOptions, ...recentOptions, ...modelOptions]
}) })
const provider = createMemo(() => const provider = createMemo(() => (props.providerID ? providers().get(props.providerID) : undefined))
props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
)
const title = createMemo(() => { const title = createMemo(() => {
const value = provider() const value = provider()
@@ -142,8 +110,8 @@ export function DialogModel(props: { providerID?: string }) {
function onSelect(providerID: string, modelID: string) { function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true }) local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list() const list = local.model.variant.list()
const cur = local.model.variant.selected() const cur = local.model.variant.current()
if (cur === "default" || (cur && list.includes(cur))) { if (cur && list.includes(cur)) {
dialog.clear() dialog.clear()
return return
} }
@@ -160,9 +128,9 @@ export function DialogModel(props: { providerID?: string }) {
actions={[ actions={[
{ {
command: "model.dialog.provider", command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers", title: connected() ? "Connect integration" : "View all integrations",
onTrigger() { onTrigger() {
dialog.replace(() => <DialogProvider />) dialog.replace(() => <DialogIntegration />)
}, },
}, },
{ {
@@ -195,3 +163,7 @@ export function sortModelOptions<T extends { footer?: string; releaseDate: strin
(option) => option.title, (option) => option.title,
) )
} }
function free(model: { cost: Array<{ input: number }> }) {
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
}
@@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
async (projectID, info): Promise<ProjectDirectory[] | undefined> => { async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
try { try {
await sdk.client.v2.projectCopy.refresh( await sdk.client.v2.projectCopy.refresh(
{ projectID, location: { directory: sdk.directory } }, { projectID, location: { directory: projectContext.instance.directory() || paths.cwd } },
{ throwOnError: true }, { throwOnError: true },
) )
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
@@ -224,7 +224,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const result = await sdk.client.v2.projectCopy const result = await sdk.client.v2.projectCopy
.remove({ .remove({
projectID: props.projectID, projectID: props.projectID,
location: { directory: sdk.directory }, location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory, directory: selected.directory,
force: false, force: false,
}) })
@@ -246,7 +246,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const forced = await sdk.client.v2.projectCopy const forced = await sdk.client.v2.projectCopy
.remove({ .remove({
projectID: props.projectID, projectID: props.projectID,
location: { directory: sdk.directory }, location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory, directory: selected.directory,
force: true, force: true,
}) })
@@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) {
<DialogPrompt <DialogPrompt
title={props.title} title={props.title}
placeholder="API key" placeholder="API key"
description={ description={() =>
{ ({
opencode: ( opencode: (
<box gap={1}> <box gap={1}>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>
@@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) {
</text> </text>
</box> </box>
), ),
}[props.providerID] ?? undefined })[props.providerID] ?? undefined
} }
onConfirm={async (value) => { onConfirm={async (value) => {
if (!value) return if (!value) return
@@ -1,203 +1,54 @@
import { createMemo, createResource, onMount } from "solid-js"
import path from "path"
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useSync } from "../context/sync" import { useData } from "../context/data"
import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
import path from "path"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { useProject } from "../context/project" import { useProject } from "../context/project"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { DialogSessionRename } from "./dialog-session-rename"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useCommandShortcut } from "../keymap" import { useCommandShortcut } from "../keymap"
import { useEvent } from "../context/event" import { Spinner } from "./spinner"
type SessionListFilter = { scope?: "project"; path?: string }
export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
const search = input.search?.trim()
return {
roots: true,
limit: search ? 30 : 100,
...(search ? { search } : {}),
...input.filter,
}
}
export function loadDialogSessionList<T>(input: {
search?: string
filter: SessionListFilter
list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
}) {
return input.list(createDialogSessionListQuery(input)).then(
(result) => result.data,
() => undefined,
)
}
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const sync = useSync() const data = useData()
const project = useProject() const project = useProject()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const event = useEvent()
const local = useLocal() const local = useLocal()
const toast = useToast() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [deleted, setDeleted] = createSignal(new Set<string>())
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9") const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [browseResults, { refetch: refetchBrowse }] = createResource( const [searchResults] = createResource(search, async (query) => {
() => sync.session.query(), if (!query) return
(filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }), const response = await sdk.client.v2.session.list(
) { search: query, limit: 50, order: "desc" },
const [searchResults, { refetch }] = createResource( { throwOnError: true },
() => ({ query: search(), filter: sync.session.query() }), )
(input) => { return { query, sessions: response.data.data }
if (!input.query) return undefined })
return loadDialogSessionList({
search: input.query,
filter: input.filter,
list: (query) => sdk.client.session.list(query),
})
},
)
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => { const sessions = createMemo(() => {
const result = searchResults() ?? browseResults() ?? sync.data.session const query = search()
const synced = new Map(sync.data.session.map((session) => [session.id, session])) if (!query) return data.session.list()
const ids = new Set(result.map((session) => session.id)) const result = searchResults()
const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => { return result?.query === query ? result.sessions : []
if (!id || ids.has(id)) return []
const session = synced.get(id)
if (session) ids.add(id)
return session ? [session] : []
})
const query = search().trim().toLowerCase()
return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
.filter((session) => !deleted().has(session.id))
.filter((session) => !query || session.title.toLowerCase().includes(query))
}) })
onCleanup(
event.on("session.deleted", (event) => {
setDeleted((current) => new Set(current).add(event.properties.info.id))
}),
)
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(err),
variant: "error",
})
return
}
const workspace = result?.data
if (!workspace) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
await refetchBrowse()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
))
}
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
const quickSwitchHint = createMemo(() => { const quickSwitchHint = createMemo(() => {
const first = quickSwitch1() const first = quickSwitch1()
const last = quickSwitch9() const last = quickSwitch9()
if (!first || !last) return undefined if (!first || !last) return
return quickSwitchRange(first, last) return quickSwitchRange(first, last)
}) })
const quickSwitchFooterHints = createMemo(() => { const quickSwitchFooterHints = createMemo(() => {
@@ -207,149 +58,70 @@ export function DialogSessionList() {
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
const sessionMap = new Map( const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session]))
sessions() const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
.filter((x) => x.parentID === undefined)
.map((x) => [x.id, x]),
)
const searchResult = searchResults()
const order = searchResult ? orderByRecency(sessions()) : browseOrder()
const current = currentSessionID()
const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const pinnedSet = new Set(pinned) const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1])) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
function buildOption(id: string, category: string) { const option = (session: SessionV2Info, category: string) => {
const x = sessionMap.get(id) const directory = session.location.directory
if (!x) return undefined const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const directory = x.path const slot = slotByID.get(session.id)
? x.directory.endsWith(x.path)
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
: undefined
: x.directory
const footer =
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id)
const gutter = isWorking
? () => <Spinner />
: slot !== undefined
? () => <text fg={theme.accent}>{slot}</text>
: undefined
return { return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, title: session.title,
bg: isDeleting ? theme.error : undefined, value: session.id,
value: x.id,
category, category,
footer, footer,
gutter, gutter:
data.session.status(session.id) === "running"
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.accent}>{slot}</text>,
} }
} }
const remaining = displayOrder const remaining = sessions()
.filter((id) => !pinnedSet.has(id)) .filter((session) => !session.parentID && !pinnedSet.has(session.id))
.map((id) => { .map((session) => {
const x = sessionMap.get(id) const date = new Date(session.time.updated).toDateString()
if (!x) return undefined return option(session, date === today ? "Today" : date)
const label = new Date(x.time.updated).toDateString()
return buildOption(id, label === today ? "Today" : label)
}) })
.filter((x) => x !== undefined)
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining] return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining]
}) })
onMount(() => { onMount(() => dialog.setSize("large"))
dialog.setSize("large")
}) const unavailable = (feature: string) =>
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
return ( return (
<DialogSelect <DialogSelect
title="Sessions" title="Sessions"
options={options()} options={options()}
skipFilter={true} skipFilter={true}
preserveSelection={true}
current={currentSessionID()} current={currentSessionID()}
onFilter={setSearch} onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => { onSelect={(option) => {
route.navigate({ route.navigate({ type: "session", sessionID: option.value })
type: "session",
sessionID: option.value,
})
dialog.clear() dialog.clear()
}} }}
actions={[ actions={[
{ {
command: "session.pin.toggle", command: "session.pin.toggle",
title: "pin/unpin", title: "pin/unpin",
onTrigger: (option: { value: string }) => { onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
local.session.togglePin(option.value)
},
}, },
{ {
command: "session.delete", command: "session.delete",
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: () => unavailable("Deleting"),
if (toDelete() === option.value) {
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
await refetchBrowse()
if (search()) await refetch()
setToDelete(undefined)
return
}
setToDelete(option.value)
},
}, },
{ {
command: "session.rename", command: "session.rename",
title: "rename", title: "rename",
onTrigger: async (option) => { onTrigger: () => unavailable("Renaming"),
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
}, },
]} ]}
footerHints={quickSwitchFooterHints()} footerHints={quickSwitchFooterHints()}
+10 -20
View File
@@ -7,32 +7,22 @@ export function DialogVariant() {
const local = useLocal() const local = useLocal()
const dialog = useDialog() const dialog = useDialog()
const options = createMemo(() => { const options = createMemo(() =>
return [ local.model.variant.list().map((variant) => ({
{ value: variant,
value: "default", title: variant,
title: "Default", onSelect: () => {
onSelect: () => { dialog.clear()
dialog.clear() local.model.variant.set(variant)
local.model.variant.set(undefined)
},
}, },
...local.model.variant.list().map((variant) => ({ })),
value: variant, )
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
return ( return (
<DialogSelect<string> <DialogSelect<string>
options={options()} options={options()}
title={"Select variant"} title={"Select variant"}
current={local.model.variant.selected()} current={local.model.variant.current()}
flat={true} flat={true}
/> />
) )
@@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: {
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast> toast: ReturnType<typeof useToast>
}) { }) {
const dir = input.sync.path.directory || input.sdk.directory const dir = input.sync.path.directory || process.cwd()
try { try {
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir }) const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
if (response.error) throw response.error if (response.error) throw response.error
@@ -400,15 +400,15 @@ export function Autocomplete(props: {
}) })
const agents = createMemo(() => { const agents = createMemo(() => {
return sync.data.agent return (data.location.agent.list() ?? [])
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
.map( .map(
(agent): AutocompleteOption => ({ (agent): AutocompleteOption => ({
display: "@" + agent.name, display: "@" + agent.id,
onSelect: () => { onSelect: () => {
insertPart(agent.name, { insertPart(agent.id, {
type: "agent", type: "agent",
name: agent.name, name: agent.id,
source: { source: {
start: 0, start: 0,
end: 0, end: 0,
+139 -167
View File
@@ -40,11 +40,10 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error" import { errorMessage } from "../../util/error"
import { formatDuration } from "../../util/format"
import { createColors, createFrames } from "../../ui/spinner" import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogIntegration } from "../dialog-integration"
import { DialogAlert } from "../../ui/dialog-alert" import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv" import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal" import { createFadeIn } from "../../util/signal"
@@ -56,6 +55,7 @@ import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace" import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move" import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment" import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -153,10 +153,17 @@ export function Prompt(props: PromptProps) {
const route = useRoute() const route = useRoute()
const project = useProject() const project = useProject()
const sync = useSync() const sync = useSync()
const data = useData()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(() =>
data.session
.list()
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length,
)
const history = usePromptHistory() const history = usePromptHistory()
const stash = usePromptStash() const stash = usePromptStash()
const keymap = useOpencodeKeymap() const keymap = useOpencodeKeymap()
@@ -207,6 +214,7 @@ export function Prompt(props: PromptProps) {
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const [cursorVersion, setCursorVersion] = createSignal(0) const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider) const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
const hasRightContent = createMemo(() => Boolean(props.right)) const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() { function promptModelWarning() {
@@ -215,8 +223,8 @@ export function Prompt(props: PromptProps) {
message: "Connect a provider to send prompts", message: "Connect a provider to send prompts",
duration: 3000, duration: 3000,
}) })
if (sync.data.provider.length === 0) { if (!connected()) {
dialog.replace(() => <DialogProviderConnect />) dialog.replace(() => <DialogIntegration />)
} }
} }
@@ -233,7 +241,7 @@ export function Prompt(props: PromptProps) {
event.on("tui.prompt.append", (evt, { workspace }) => { event.on("tui.prompt.append", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== project.workspace.current()) return
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
input.insertText(evt.properties.text) input.insertText(evt.data.text)
setTimeout(() => { setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly // setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
@@ -303,28 +311,20 @@ export function Prompt(props: PromptProps) {
), ),
) )
// Initialize agent/model/variant from last user message when session changes // Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined let syncedSessionID: string | undefined
createEffect(() => { createEffect(() => {
const sessionID = props.sessionID const sessionID = props.sessionID
const msg = lastUserMessage() if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
const session = data.session.get(sessionID)
if (sessionID !== syncedSessionID) { if (!session) return
if (!sessionID || !msg) return const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
syncedSessionID = sessionID if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
// Only set agent if it's a primary agent (not a subagent) local.model.variant.set(session.model.variant)
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
// Keep command line --agent if specified.
if (!args.agent) local.agent.set(msg.agent)
if (msg.model) {
local.model.set(msg.model)
local.model.variant.set(msg.model.variant)
}
}
} }
syncedSessionID = sessionID
}) })
const promptCommands = createMemo(() => const promptCommands = createMemo(() =>
@@ -389,7 +389,7 @@ export function Prompt(props: PromptProps) {
name: "session.interrupt", name: "session.interrupt",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: status().type !== "idle", enabled: status() === "running",
run: () => { run: () => {
if (auto()?.visible) return if (auto()?.visible) return
if (!input.focused) return if (!input.focused) return
@@ -407,7 +407,7 @@ export function Prompt(props: PromptProps) {
}, 5000) }, 5000)
if (store.interrupt >= 2) { if (store.interrupt >= 2) {
void sdk.client.session.abort({ void sdk.client.v2.session.interrupt({
sessionID: props.sessionID, sessionID: props.sessionID,
}) })
setStore("interrupt", 0) setStore("interrupt", 0)
@@ -982,6 +982,7 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current() const variant = local.model.variant.current()
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
if (sessionID == null) { if (sessionID == null) {
const selectedWorkspace = workspace.selection() const selectedWorkspace = workspace.selection()
@@ -991,10 +992,9 @@ export function Prompt(props: PromptProps) {
if (move.pending() && !directory) return false if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress()) finishMoveProgress = Boolean(move.progress())
const res = await sdk.client.session.create({ const res = await sdk.client.v2.session.create({
directory, location: directory ? { directory, workspaceID } : undefined,
workspace: workspaceID, agent: agent.id,
agent: agent.name,
model: { model: {
providerID: selectedModel.providerID, providerID: selectedModel.providerID,
id: selectedModel.modelID, id: selectedModel.modelID,
@@ -1004,8 +1004,6 @@ export function Prompt(props: PromptProps) {
if (res.error) { if (res.error) {
if (finishMoveProgress) move.finishSubmit() if (finishMoveProgress) move.finishSubmit()
console.log("Creating a session failed:", res.error)
toast.show({ toast.show({
message: "Creating a session failed. Open console for more details.", message: "Creating a session failed. Open console for more details.",
variant: "error", variant: "error",
@@ -1014,7 +1012,8 @@ export function Prompt(props: PromptProps) {
return true return true
} }
sessionID = res.data.id sessionID = res.data.data.id
session = res.data.data
} }
const inputText = expandTrackedPastedText( const inputText = expandTrackedPastedText(
@@ -1054,7 +1053,7 @@ export function Prompt(props: PromptProps) {
move.startSubmit() move.startSubmit()
void sdk.client.session.shell({ void sdk.client.session.shell({
sessionID, sessionID,
agent: agent.name, agent: agent.id,
model: { model: {
providerID: selectedModel.providerID, providerID: selectedModel.providerID,
modelID: selectedModel.modelID, modelID: selectedModel.modelID,
@@ -1078,39 +1077,72 @@ export function Prompt(props: PromptProps) {
sessionID, sessionID,
command: command.slice(1), command: command.slice(1),
arguments: args, arguments: args,
agent: agent.name, agent: agent.id,
model: `${selectedModel.providerID}/${selectedModel.modelID}`, model: `${selectedModel.providerID}/${selectedModel.modelID}`,
variant, variant,
parts: nonTextParts.filter((x) => x.type === "file"), parts: nonTextParts.filter((x) => x.type === "file"),
}) })
} else { } else {
move.startSubmit() move.startSubmit()
sdk.client.session if (!session) {
.prompt( await data.session.refresh(sessionID)
session = data.session.get(sessionID)
}
if (session?.agent !== agent.id) {
await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true })
}
if (
session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID ||
session.model.variant !== variant
) {
await sdk.client.v2.session.switchModel(
{ {
sessionID, sessionID,
...selectedModel, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
agent: agent.name,
model: selectedModel,
variant,
parts: [
...editorParts,
{
type: "text",
text: inputText,
},
...nonTextParts,
],
}, },
{ throwOnError: true }, { throwOnError: true },
) )
.catch((error) => { }
toast.show({ const result = await sdk.client.v2.session.prompt({
title: "Failed to send prompt", sessionID,
message: errorMessage(error), prompt: {
variant: "error", text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
}) files: nonTextParts.flatMap((part) =>
}) part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: part.source
? {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
: undefined,
},
]
: [],
),
agents: nonTextParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
},
})
if (result.error) {
toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" })
return false
}
if (editorParts.length > 0) editor.markSelectionSent() if (editorParts.length > 0) editor.markSelectionSent()
} }
history.append({ history.append({
@@ -1284,7 +1316,7 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") return theme.primary if (store.mode === "shell") return theme.primary
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return theme.border if (!agent) return theme.border
return local.agent.color(agent.name) return local.agent.color(agent.id)
}) })
const showVariant = createMemo(() => { const showVariant = createMemo(() => {
@@ -1315,10 +1347,10 @@ export function Prompt(props: PromptProps) {
const spinnerDef = createMemo(() => { const spinnerDef = createMemo(() => {
const agent = const agent =
status().type !== "idle" status() === "running"
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current()) ? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
: local.agent.current() : local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border const color = agent ? local.agent.color(agent.id) : theme.border
return { return {
frames: createFrames({ frames: createFrames({
color, color,
@@ -1439,7 +1471,7 @@ export function Prompt(props: PromptProps) {
{(agent) => ( {(agent) => (
<> <>
<text fg={fadeColor(highlight(), agentMetaAlpha())}> <text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
</text> </text>
<Show when={store.mode === "normal"}> <Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -1501,78 +1533,20 @@ export function Prompt(props: PromptProps) {
</box> </box>
<box width="100%" flexDirection="row" justifyContent="space-between"> <box width="100%" flexDirection="row" justifyContent="space-between">
<Switch> <Switch>
<Match when={status().type !== "idle"}> <Match when={status() === "running"}>
<box <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
flexDirection="row" <box marginLeft={1}>
gap={1} <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
flexGrow={1} <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
justifyContent={status().type === "retry" ? "space-between" : "flex-start"} </Show>
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box> </box>
<Show when={activeSubagents()}>
{(count) => (
<Spinner color={theme.textMuted}>
{count()} active subagent{count() === 1 ? "" : "s"}
</Spinner>
)}
</Show>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}> <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "} esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
@@ -1633,41 +1607,39 @@ export function Prompt(props: PromptProps) {
</Match> </Match>
<Match when={true}>{props.hint ?? <text />}</Match> <Match when={true}>{props.hint ?? <text />}</Match>
</Switch> </Switch>
<Show when={status().type !== "retry"}> <box gap={2} flexDirection="row">
<box gap={2} flexDirection="row"> <Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}> {(file) => (
{(file) => ( <text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text> )}
)} </Show>
</Show> <Switch>
<Switch> <Match when={store.mode === "normal"}>
<Match when={store.mode === "normal"}> <Switch>
<Switch> <Match when={usage()}>
<Match when={usage()}> {(item) => (
{(item) => ( <text fg={theme.textMuted} wrapMode="none">
<text fg={theme.textMuted} wrapMode="none"> {[item().context, item().cost].filter(Boolean).join(" · ")}
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text> </text>
</Match> )}
</Switch> </Match>
<text fg={theme.text}> <Match when={true}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span> <text fg={theme.text}>
</text> {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</Match> </text>
<Match when={store.mode === "shell"}> </Match>
<text fg={theme.text}> </Switch>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span> <text fg={theme.text}>
</text> {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</Match> </text>
</Switch> </Match>
</box> <Match when={store.mode === "shell"}>
</Show> <text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</box> </box>
</box> </box>
<Autocomplete <Autocomplete
+1 -1
View File
@@ -40,7 +40,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const result = await sdk.client.v2.projectCopy.create( const result = await sdk.client.v2.projectCopy.create(
{ {
projectID, projectID,
location: { directory: sdk.directory }, location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree", strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)), directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: generated.data.name, name: generated.data.name,
@@ -0,0 +1,32 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { attempt: number; error?: string }) {
const theme = useTheme().theme
return (
<box
position="absolute"
zIndex={10_000}
top={0}
right={0}
bottom={0}
left={0}
backgroundColor={theme.background}
alignItems="center"
justifyContent="center"
>
<box width={54} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<text fg={theme.text}>Connection lost</text>
<Spinner color={theme.textMuted}>Reconnecting to server...</Spinner>
<text fg={theme.textMuted}>Attempt {props.attempt}</text>
<Show when={props.error}>
<text fg={theme.error} wrapMode="word">
{props.error}
</text>
</Show>
</box>
</box>
)
}
+6 -5
View File
@@ -1,12 +1,13 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useData } from "../context/data"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
export function useConnected() { export function useConnected() {
const data = useData()
const sync = useSync() const sync = useSync()
return createMemo(() => return createMemo(
sync.data.provider.some( () =>
(provider) => (data.location.integration.list() ?? []).some((integration) => integration.connections.length > 0) ||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0), sync.data.console_state.consoleManagedProviders.length > 0,
),
) )
} }
+1 -1
View File
@@ -123,7 +123,7 @@ export const Definitions = {
model_cycle_favorite: keybind("none", "Next favorite model"), model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
mcp_list: keybind("none", "List MCP servers"), mcp_list: keybind("none", "List MCP servers"),
provider_connect: keybind("none", "Connect provider"), provider_connect: keybind("none", "Connect integration"),
console_org_switch: keybind("none", "Switch console organization"), console_org_switch: keybind("none", "Switch console organization"),
agent_list: keybind("<leader>a", "List agents"), agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"), agent_cycle: keybind("tab", "Next agent"),
+206 -89
View File
@@ -21,8 +21,9 @@ import type {
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useEvent } from "./event" import { createSignal, onCleanup } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
export type DataSessionStatus = "idle" | "running"
type LocationData = { type LocationData = {
agent?: AgentV2Info[] agent?: AgentV2Info[]
@@ -37,6 +38,7 @@ type LocationData = {
type Data = { type Data = {
session: { session: {
info: Record<string, SessionV2Info> info: Record<string, SessionV2Info>
status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]> message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]> permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]> question: Record<string, QuestionV2Request[]>
@@ -61,6 +63,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const [store, setStore] = createStore<Data>({ const [store, setStore] = createStore<Data>({
session: { session: {
info: {}, info: {},
status: {},
message: {}, message: {},
permission: {}, permission: {},
question: {}, question: {},
@@ -72,35 +75,37 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
const sdk = useSDK() const sdk = useSDK()
const events = useEvent()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({ const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(), directory: process.cwd(),
}) })
const messageIndex = new Map<string, Map<string, number>>()
const message = { const message = {
update(sessionID: string, fn: (messages: SessionMessage[]) => void) { update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
setStore( setStore(
"session", "session",
"message", "message",
produce((draft) => { produce((draft) => {
fn((draft[sessionID] ??= [])) fn((draft[sessionID] ??= []), index(sessionID))
}), }),
) )
}, },
prepend(messages: SessionMessage[], item: SessionMessage) { append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
if (messages.some((existing) => existing.id === item.id)) return if (index.has(item.id)) return
messages.unshift(item) index.set(item.id, messages.length)
messages.push(item)
}, },
activeAssistant(messages: SessionMessage[]) { activeAssistant(messages: SessionMessage[]) {
const item = messages.find((item) => item.type === "assistant" && !item.time.completed) const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined return item?.type === "assistant" ? item : undefined
}, },
assistant(messages: SessionMessage[], messageID: string) { assistant(messages: SessionMessage[], index: Map<string, number>, messageID: string) {
const item = messages.find((item) => item.type === "assistant" && item.id === messageID) const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined return item?.type === "assistant" ? item : undefined
}, },
activeShell(messages: SessionMessage[], callID: string) { activeShell(messages: SessionMessage[], callID: string) {
const item = messages.find((item) => item.type === "shell" && item.callID === callID) const item = messages.findLast((item) => item.type === "shell" && item.callID === callID)
return item?.type === "shell" ? item : undefined return item?.type === "shell" ? item : undefined
}, },
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
@@ -121,8 +126,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}, },
} }
function index(sessionID: string) {
const existing = messageIndex.get(sessionID)
if (existing) return existing
const created = new Map<string, number>()
messageIndex.set(sessionID, created)
return created
}
function handleEvent(event: V2Event) { function handleEvent(event: V2Event) {
switch (event.type) { switch (event.type) {
case "session.created":
void result.session.refresh(event.data.sessionID)
break
case "catalog.updated": case "catalog.updated":
void Promise.all([ void Promise.all([
result.location.model.refresh(event.location), result.location.model.refresh(event.location),
@@ -130,8 +146,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
]) ])
break break
case "session.next.agent.switched": case "session.next.agent.switched":
message.update(event.data.sessionID, (draft) => { if (store.session.info[event.data.sessionID])
message.prepend(draft, { setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "agent-switched", type: "agent-switched",
agent: event.data.agent, agent: event.data.agent,
@@ -140,8 +158,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.model.switched": case "session.next.model.switched":
message.update(event.data.sessionID, (draft) => { if (store.session.info[event.data.sessionID])
message.prepend(draft, { setStore("session", "info", event.data.sessionID, "model", event.data.model)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "model-switched", type: "model-switched",
model: event.data.model, model: event.data.model,
@@ -150,8 +170,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.prompted": { case "session.next.prompted": {
message.update(event.data.sessionID, (draft) => { setStore("session", "status", event.data.sessionID, "running")
message.prepend(draft, { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "user", type: "user",
text: event.data.prompt.text, text: event.data.prompt.text,
@@ -165,8 +186,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.prompt.admitted": case "session.next.prompt.admitted":
break break
case "session.next.context.updated": case "session.next.context.updated":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
message.prepend(draft, { message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "system", type: "system",
text: event.data.text, text: event.data.text,
@@ -175,8 +196,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.synthetic": case "session.next.synthetic":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
message.prepend(draft, { message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "synthetic", type: "synthetic",
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
@@ -186,8 +207,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.shell.started": case "session.next.shell.started":
message.update(event.data.sessionID, (draft) => { setStore("session", "status", event.data.sessionID, "running")
message.prepend(draft, { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "shell", type: "shell",
callID: event.data.callID, callID: event.data.callID,
@@ -198,7 +220,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.shell.ended": case "session.next.shell.ended":
message.update(event.data.sessionID, (draft) => { setStore("session", "status", event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft, index) => {
const match = message.activeShell(draft, event.data.callID) const match = message.activeShell(draft, event.data.callID)
if (!match) return if (!match) return
match.output = event.data.output match.output = event.data.output
@@ -206,11 +229,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.step.started": case "session.next.step.started":
message.update(event.data.sessionID, (draft) => { setStore("session", "status", event.data.sessionID, "running")
if (draft.some((message) => message.id === event.data.assistantMessageID)) return message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft) const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
message.prepend(draft, { message.append(draft, index, {
id: event.data.assistantMessageID, id: event.data.assistantMessageID,
type: "assistant", type: "assistant",
agent: event.data.agent, agent: event.data.agent,
@@ -222,8 +246,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.step.ended": case "session.next.step.ended":
message.update(event.data.sessionID, (draft) => { setStore("session", "status", event.data.sessionID, event.data.finish === "tool-calls" ? "running" : "idle")
const currentAssistant = message.assistant(draft, event.data.assistantMessageID) message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = event.data.finish currentAssistant.finish = event.data.finish
@@ -234,8 +259,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.step.failed": case "session.next.step.failed":
message.update(event.data.sessionID, (draft) => { setStore("session", "status", event.data.sessionID, "idle")
const currentAssistant = message.assistant(draft, event.data.assistantMessageID) message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = "error" currentAssistant.finish = "error"
@@ -243,8 +269,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.text.started": case "session.next.text.started":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({ message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text", type: "text",
id: event.data.textID, id: event.data.textID,
text: "", text: "",
@@ -252,20 +278,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.text.delta": case "session.next.text.delta":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID) const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
break break
case "session.next.text.ended": case "session.next.text.ended":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID) const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
if (match) match.text = event.data.text if (match) match.text = event.data.text
}) })
break break
case "session.next.tool.input.started": case "session.next.tool.input.started":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({ message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.name, name: event.data.name,
@@ -275,20 +301,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.tool.input.delta": case "session.next.tool.input.delta":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
if (match?.state.status === "pending") match.state.input += event.data.delta if (match?.state.status === "pending") match.state.input += event.data.delta
}) })
break break
case "session.next.tool.input.ended": case "session.next.tool.input.ended":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
if (match?.state.status === "pending") match.state.input = event.data.text if (match?.state.status === "pending") match.state.input = event.data.text
}) })
break break
case "session.next.tool.called": case "session.next.tool.called":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
if (!match) return if (!match) return
match.time.ran = event.data.timestamp match.time.ran = event.data.timestamp
match.provider = event.data.provider match.provider = event.data.provider
@@ -296,16 +322,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.tool.progress": case "session.next.tool.progress":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
if (match?.state.status !== "running") return if (match?.state.status !== "running") return
match.state.structured = event.data.structured match.state.structured = event.data.structured
match.state.content = [...event.data.content] match.state.content = [...event.data.content]
}) })
break break
case "session.next.tool.success": case "session.next.tool.success":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
if (match?.state.status !== "running") return if (match?.state.status !== "running") return
match.state = { match.state = {
status: "completed", status: "completed",
@@ -323,8 +349,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.tool.failed": case "session.next.tool.failed":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = { match.state = {
status: "error", status: "error",
@@ -343,43 +369,47 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.next.reasoning.started": case "session.next.reasoning.started":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({ message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning", type: "reasoning",
id: event.data.reasoningID, id: event.data.reasoningID,
text: "", text: "",
providerMetadata: event.data.providerMetadata, providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.reasoning.delta": case "session.next.reasoning.delta":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning( const match = message.latestReasoning(
message.assistant(draft, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID, event.data.reasoningID,
) )
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
break break
case "session.next.reasoning.ended": case "session.next.reasoning.ended":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning( const match = message.latestReasoning(
message.assistant(draft, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID, event.data.reasoningID,
) )
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
} }
}) })
break break
case "session.next.retried": case "session.next.retried":
case "session.next.compaction.started": case "session.next.compaction.started":
setStore("session", "status", event.data.sessionID, "running")
break
case "session.next.compaction.delta": case "session.next.compaction.delta":
break break
case "session.next.compaction.ended": case "session.next.compaction.ended":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft, index) => {
message.prepend(draft, { message.append(draft, index, {
id: event.data.messageID, id: event.data.messageID,
type: "compaction", type: "compaction",
reason: event.data.reason, reason: event.data.reason,
@@ -389,6 +419,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
}) })
break break
case "permission.v2.asked":
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
setStore("session", "permission", event.data.sessionID, [
...(store.session.permission[event.data.sessionID] ?? []),
event.data,
])
break
case "permission.v2.replied":
setStore(
"session",
"permission",
event.data.sessionID,
(store.session.permission[event.data.sessionID] ?? []).filter(
(request) => request.id !== event.data.requestID,
),
)
break
case "question.v2.asked":
if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
setStore("session", "question", event.data.sessionID, [
...(store.session.question[event.data.sessionID] ?? []),
event.data,
])
break
case "question.v2.replied":
case "question.v2.rejected":
setStore(
"session",
"question",
event.data.sessionID,
(store.session.question[event.data.sessionID] ?? []).filter(
(request) => request.id !== event.data.requestID,
),
)
break
case "reference.updated": case "reference.updated":
void result.location.reference.refresh() void result.location.reference.refresh()
break break
@@ -402,33 +467,61 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
} }
} }
onMount(() => {
const unsub = events.subscribe((event, metadata) => {
handleEvent({
...event,
data: event.properties,
location: { directory: metadata.directory, workspaceID: metadata.workspace },
} as V2Event)
})
onCleanup(unsub)
})
const result = { const result = {
on: sdk.event.on,
listen: sdk.event.listen,
connection: {
status() {
return sdk.connection.status()
},
attempt() {
return sdk.connection.attempt()
},
error() {
return sdk.connection.error()
},
},
session: { session: {
list() {
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
},
get(sessionID: string) { get(sessionID: string) {
return store.session.info[sessionID] return store.session.info[sessionID]
}, },
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
async refresh(sessionID: string) { async refresh(sessionID: string) {
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true }) const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
setStore("session", "info", sessionID, result.data.data) setStore("session", "info", sessionID, result.data.data)
}, },
message: { message: {
ids(sessionID: string) {
return (store.session.message[sessionID] ?? []).map((message) => message.id)
},
list(sessionID: string) { list(sessionID: string) {
return store.session.message[sessionID] return store.session.message[sessionID] ?? []
},
get(sessionID: string, messageID: string) {
const messages = store.session.message[sessionID]
const position = messageIndex.get(sessionID)?.get(messageID)
return position === undefined ? undefined : messages?.[position]
}, },
async refresh(sessionID: string) { async refresh(sessionID: string) {
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true }) setStore("session", "message", sessionID, [])
setStore("session", "message", sessionID, result.data.data) messageIndex.set(sessionID, new Map())
const response = await sdk.client.v2.session.messages(
{ sessionID, limit: 200, order: "desc" },
{ throwOnError: true },
)
const loaded = response.data.data.toReversed()
const live = store.session.message[sessionID] ?? []
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live]
.filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index)
.toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
}, },
}, },
permission: { permission: {
@@ -479,7 +572,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "agent", result.data.data) setStore("location", key, { ...store.location[key], agent: result.data.data })
}, },
}, },
command: { command: {
@@ -489,7 +582,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "command", result.data.data) setStore("location", key, { ...store.location[key], command: result.data.data })
}, },
}, },
integration: { integration: {
@@ -502,7 +595,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
{ throwOnError: true }, { throwOnError: true },
) )
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "integration", result.data.data) setStore("location", key, { ...store.location[key], integration: result.data.data })
}, },
}, },
model: { model: {
@@ -512,7 +605,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "model", result.data.data) setStore("location", key, { ...store.location[key], model: result.data.data })
}, },
}, },
provider: { provider: {
@@ -522,7 +615,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "provider", result.data.data) setStore("location", key, { ...store.location[key], provider: result.data.data })
}, },
}, },
reference: { reference: {
@@ -532,7 +625,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "reference", result.data.data) setStore("location", key, { ...store.location[key], reference: result.data.data })
}, },
}, },
skill: { skill: {
@@ -542,14 +635,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location) const key = locationKey(result.data.location)
setStore("location", key, "skill", result.data.data) setStore("location", key, { ...store.location[key], skill: result.data.data })
}, },
}, },
}, },
} }
onMount(() => { async function bootstrap() {
void Promise.allSettled([ const settled = await Promise.allSettled([
sdk.client.v2.session
.list({ limit: 50, order: "desc" }, { throwOnError: true })
.then((response) =>
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data.data) draft[session.id] = session
}),
),
),
sdk.client.v2.session.active({ throwOnError: true }).then((response) =>
setStore(
"session",
"status",
Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])),
),
),
result.location.refresh(), result.location.refresh(),
result.location.agent.refresh(), result.location.agent.refresh(),
result.location.integration.refresh(), result.location.integration.refresh(),
@@ -558,11 +669,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.reference.refresh(), result.location.reference.refresh(),
result.location.command.refresh(), result.location.command.refresh(),
result.location.skill.refresh(), result.location.skill.refresh(),
]).then((settled) => { ])
for (const failure of settled.filter((item) => item.status === "rejected")) for (const failure of settled.filter((item) => item.status === "rejected"))
console.error("Failed to refresh default location data", failure.reason) console.error("Failed to refresh default location data", failure.reason)
}) }
})
onCleanup(
sdk.event.listen(({ details }) => {
handleEvent(details)
if (details.type === "server.connected") void bootstrap()
}),
)
return result return result
}, },
+10 -14
View File
@@ -1,31 +1,27 @@
import type { Event } from "@opencode-ai/sdk/v2" import type { V2Event } from "@opencode-ai/sdk/v2"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
type EventMetadata = { type EventMetadata = {
directory: string directory: string | undefined
workspace: string | undefined workspace: string | undefined
} }
export function useEvent() { export function useEvent() {
const sdk = useSDK() const sdk = useSDK()
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { function subscribe(handler: (event: V2Event, metadata: EventMetadata) => void) {
return sdk.event.on("event", (event) => { return sdk.event.listen(({ details }) => {
if (event.payload.type === "sync") { if (details.type === "server.connected") return
return handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID })
}
handler(event.payload, { directory: event.directory, workspace: event.workspace })
}) })
} }
function on<T extends Event["type"]>( function on<T extends V2Event["type"]>(
type: T, type: T,
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void, handler: (event: Extract<V2Event, { type: T }>, metadata: EventMetadata) => void,
) { ) {
return subscribe((event: Event, metadata: EventMetadata) => { return sdk.event.on(type, (event) => {
if (event.type !== type) return handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
handler(event as Extract<Event, { type: T }>, metadata)
}) })
} }
+47 -39
View File
@@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTheme } from "./theme" import { useTheme } from "./theme"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useRoute } from "./route" import { useRoute } from "./route"
import { useData } from "./data"
export type LocalTheme = { export type LocalTheme = {
secondary: RGBA secondary: RGBA
@@ -51,6 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
const sync = useSync() const sync = useSync()
const data = useData()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const theme = useTheme().theme const theme = useTheme().theme
@@ -58,8 +60,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const paths = useTuiPaths() const paths = useTuiPaths()
function isModelValid(model: { providerID: string; modelID: string }) { function isModelValid(model: { providerID: string; modelID: string }) {
const provider = sync.data.provider.find((item) => item.id === model.providerID) return !!data.location.model
return !!provider?.models[model.modelID] .list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
} }
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) { function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
@@ -71,8 +74,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
function createAgent() { function createAgent() {
const agents = createMemo(() => sync.data.agent.filter((agent) => agent.mode !== "subagent" && !agent.hidden)) const agents = createMemo(() =>
const visibleAgents = createMemo(() => sync.data.agent.filter((agent) => !agent.hidden)) (data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
@@ -90,30 +95,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents() return agents()
}, },
current() { current() {
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0) return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0)
}, },
set(name: string) { set(id: string) {
if (!agents().some((x) => x.name === name)) if (!agents().some((agent) => agent.id === id))
return toast.show({ return toast.show({
variant: "warning", variant: "warning",
message: `Agent not found: ${name}`, message: `Agent not found: ${id}`,
duration: 3000, duration: 3000,
}) })
setAgentStore("current", name) setAgentStore("current", id)
}, },
move(direction: 1 | -1) { move(direction: 1 | -1) {
batch(() => { batch(() => {
const current = this.current() const current = this.current()
if (!current) return if (!current) return
let next = agents().findIndex((x) => x.name === current.name) + direction let next = agents().findIndex((agent) => agent.id === current.id) + direction
if (next < 0) next = agents().length - 1 if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0 if (next >= agents().length) next = 0
const value = agents()[next] const value = agents()[next]
setAgentStore("current", value.name) setAgentStore("current", value.id)
}) })
}, },
color(name: string) { color(id: string) {
const index = visibleAgents().findIndex((x) => x.name === name) const index = visibleAgents().findIndex((agent) => agent.id === id)
if (index === -1) return colors()[0] if (index === -1) return colors()[0]
const agent = visibleAgents()[index] const agent = visibleAgents()[index]
@@ -218,15 +223,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
const provider = sync.data.provider[0] const model = data.location.model.list()?.[0]
if (!provider) return undefined
const defaultModel = sync.data.provider_default[provider.id]
const firstModel = Object.values(provider.models)[0]
const model = defaultModel ?? firstModel?.id
if (!model) return undefined if (!model) return undefined
return { return {
providerID: provider.id, providerID: model.providerID,
modelID: model, modelID: model.id,
} }
}) })
@@ -234,8 +235,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const a = agent.current() const a = agent.current()
return ( return (
getFirstValidModel( getFirstValidModel(
() => a && modelStore.model[a.name], () => a && modelStore.model[a.id],
() => a && a.model, () => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel, fallbackModel,
) ?? undefined ) ?? undefined
) )
@@ -261,12 +262,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false, reasoning: false,
} }
} }
const provider = sync.data.provider.find((item) => item.id === value.providerID) const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = provider?.models[value.modelID] const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return { return {
provider: provider?.name ?? value.providerID, provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID, model: info?.name ?? value.modelID,
reasoning: info?.capabilities?.reasoning ?? false, reasoning: info?.variants.length !== 0,
} }
}), }),
cycle(direction: 1 | -1) { cycle(direction: 1 | -1) {
@@ -282,7 +285,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!val) return if (!val) return
const a = agent.current() const a = agent.current()
if (!a) return if (!a) return
setModelStore("model", a.name, { ...val }) setModelStore("model", a.id, { ...val })
}, },
cycleFavorite(direction: 1 | -1) { cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item)) const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -310,7 +313,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return if (!next) return
const a = agent.current() const a = agent.current()
if (!a) return if (!a) return
setModelStore("model", a.name, { ...next }) setModelStore("model", a.id, { ...next })
setModelStore("recent", recentModels(next, modelStore.recent)) setModelStore("recent", recentModels(next, modelStore.recent))
save() save()
}, },
@@ -326,7 +329,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const a = agent.current() const a = agent.current()
if (!a) return if (!a) return
setModelStore("model", a.name, model) setModelStore("model", a.id, model)
if (options?.recent) { if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent)) setModelStore("recent", recentModels(model, modelStore.recent))
save() save()
@@ -361,21 +364,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentModel() const m = currentModel()
if (!m) return undefined if (!m) return undefined
const key = `${m.providerID}/${m.modelID}` const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key] return modelStore.variant[key] ?? "default"
}, },
current() { current() {
const v = this.selected() const v = this.selected()
if (!v) return undefined if (!v) return undefined
if (!this.list().includes(v)) return undefined if (v !== "default" && this.list().includes(v)) return v
return v const m = currentModel()!
return (
data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)?.request.variant ?? "default"
)
}, },
list() { list() {
const m = currentModel() const m = currentModel()
if (!m) return [] if (!m) return []
const provider = sync.data.provider.find((item) => item.id === m.providerID) const info = data.location.model
const info = provider?.models[m.modelID] .list()
if (!info?.variants) return [] ?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return Object.keys(info.variants) return info?.variants.map((variant) => variant.id) ?? []
}, },
set(value: string | undefined) { set(value: string | undefined) {
const m = currentModel() const m = currentModel()
@@ -394,7 +402,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const index = variants.indexOf(current) const index = variants.indexOf(current)
if (index === -1 || index === variants.length - 1) { if (index === -1 || index === variants.length - 1) {
this.set(undefined) this.set(variants[0])
return return
} }
this.set(variants[index + 1]) this.set(variants[index + 1])
@@ -466,7 +474,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
event.on("session.deleted", (evt) => { event.on("session.deleted", (evt) => {
prune(evt.properties.info.id) prune(evt.data.info.id)
}) })
return { return {
@@ -521,10 +529,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createEffect(() => { createEffect(() => {
const value = agent.current() const value = agent.current()
if (!value?.model) return if (!value?.model) return
if (isModelValid(value.model)) return if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return
toast.show({ toast.show({
variant: "warning", variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`,
duration: 3000, duration: 3000,
}) })
}) })
+7 -7
View File
@@ -1,4 +1,4 @@
import { batch } from "solid-js" import { batch, onCleanup } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2" import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
@@ -16,7 +16,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
state: "", state: "",
config: "", config: "",
worktree: "", worktree: "",
directory: sdk.directory ?? "", directory: process.cwd(),
} satisfies Path } satisfies Path
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -67,11 +67,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}) })
} }
sdk.event.on("event", (event) => { onCleanup(
if (event.payload.type === "workspace.status") { sdk.event.on("workspace.status", (event) => {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status) setStore("workspace", "status", event.data.workspaceID, event.data.status)
} }),
}) )
return { return {
data: store, data: store,
+127 -133
View File
@@ -1,151 +1,145 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@opencode-ai/core/flag/flag" import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = { export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting"
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
} type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
const connectTimeout = 2_000
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
url: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) => {
const abort = new AbortController() const abort = new AbortController()
let sse: AbortController | undefined let client = props.client
const events = createGlobalEmitter<SDKEventMap>()
function createSDK() { const [connection, setConnection] = createStore<{
return createOpencodeClient({ status: SDKConnectionStatus
baseUrl: props.url, attempt: number
signal: abort.signal, error?: string
directory: props.directory, }>({
fetch: props.fetch, status: "connecting",
headers: props.headers, attempt: 0,
})
}
let sdk = createSDK()
const handlers = new Set<(event: GlobalEvent) => void>()
const emitter = {
emit(_type: "event", event: GlobalEvent) {
for (const handler of handlers) handler(event)
},
on(_type: "event", handler: (event: GlobalEvent) => void) {
handlers.add(handler)
return () => {
handlers.delete(handler)
}
},
}
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit("event", event)
}
})
}
const handleEvent = (event: GlobalEvent) => {
queue.push(event)
const elapsed = Date.now() - last
if (timer) return
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
return
}
flush()
}
function startSSE() {
sse?.abort()
const ctrl = new AbortController()
sse = ctrl
;(async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({
signal: ctrl.signal,
sseMaxRetryAttempts: 0,
})
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(() => {})
}
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
handleEvent(event)
}
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
}
})().catch(() => {})
}
onMount(async () => {
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
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(() => {})
}
} else {
startSSE()
}
}) })
let stream: AbortController | undefined
let pending: Promise<void> | undefined
function start() {
stream?.abort()
const controller = new AbortController()
const current = client
let connected!: () => void
const ready = new Promise<void>((resolve) => {
connected = resolve
})
stream = controller
void (async () => {
let attempt = 0
while (!abort.signal.aborted && !controller.signal.aborted) {
const connection = new AbortController()
const cancel = () => connection.abort(controller.signal.reason)
const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
const error = await (async () => {
const response = await current.v2.event.subscribe({
signal: connection.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const iterator = response.stream[Symbol.asyncIterator]()
const first = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return
if (first.done)
return connection.signal.reason instanceof Error
? connection.signal.reason
: new Error("Event stream disconnected")
clearTimeout(timeout)
attempt = 0
setConnection({ status: "connected", attempt: 0, error: undefined })
events.emit(first.value.type, first.value)
connected()
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return
if (event.done) return new Error("Event stream disconnected")
events.emit(event.value.type, event.value)
}
})()
.catch((error) => error)
.finally(() => {
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
})
if (abort.signal.aborted || controller.signal.aborted) return
attempt += 1
setConnection({
status: "reconnecting",
attempt,
error: error instanceof Error ? error.message : String(error),
})
await wait(250, controller.signal)
}
})()
return ready
}
const reload = props.reload
? () => {
if (pending) return pending
pending = Promise.resolve()
.then(props.reload)
.then(async (next) => {
client = next
if (!abort.signal.aborted) await start()
})
.finally(() => {
pending = undefined
})
return pending
}
: undefined
onMount(() => void start())
onCleanup(() => { onCleanup(() => {
abort.abort() abort.abort()
sse?.abort() stream?.abort()
if (timer) clearTimeout(timer) events.clear()
handlers.clear()
}) })
return { return {
get client() { get client() {
return sdk return client
}, },
directory: props.directory, event: {
event: emitter, on: events.on,
fetch: props.fetch ?? fetch, listen: events.listen,
url: props.url, },
connection: {
status() {
return connection.status
},
attempt() {
return connection.attempt
},
error() {
return connection.error
},
},
reload,
} }
}, },
}) })
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
+42 -574
View File
@@ -1,55 +1,33 @@
import type { import type {
Message,
Agent, Agent,
Provider,
Session,
Part,
Config,
Todo,
Command, Command,
PermissionRequest, Config,
QuestionRequest,
LspStatus,
McpStatus,
McpResource,
FormatterStatus,
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
SnapshotFileDiff,
ConsoleState, ConsoleState,
FormatterStatus,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
PermissionRequest,
Provider,
ProviderAuthMethod,
ProviderListResponse,
QuestionRequest,
Session,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore } from "solid-js/store"
import { useProject } from "./project"
import { useEvent } from "./event"
import { useSDK } from "./sdk"
import { useTuiStartup } from "./runtime"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useExit } from "./exit" import { useProject } from "./project"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import path from "path"
import { useKV } from "./kv"
const emptyConsoleState: ConsoleState = { const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [], consoleManagedProviders: [],
switchableOrgCount: 0, switchableOrgCount: 0,
} }
function search<T>(items: T[], target: string, key: (item: T) => string) {
let left = 0
let right = items.length - 1
while (left <= right) {
const middle = Math.floor((left + right) / 2)
const value = key(items[middle])
if (value === target) return { found: true, index: middle }
if (value < target) left = middle + 1
else right = middle - 1
}
return { found: false, index: left }
}
export const { export const {
context: SyncContext, context: SyncContext,
use: useSync, use: useSync,
@@ -57,8 +35,7 @@ export const {
} = createSimpleContext({ } = createSimpleContext({
name: "Sync", name: "Sync",
init: () => { init: () => {
const startup = useTuiStartup() const project = useProject()
const kv = useKV()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
provider: Provider[] provider: Provider[]
@@ -71,39 +48,23 @@ export const {
provider_auth: Record<string, ProviderAuthMethod[]> provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
permission: { permission: Record<string, PermissionRequest[]>
[sessionID: string]: PermissionRequest[] question: Record<string, QuestionRequest[]>
}
question: {
[sessionID: string]: QuestionRequest[]
}
config: Config config: Config
session: Session[] session: Session[]
session_status: { session_diff: Record<string, SnapshotFileDiff[]>
[sessionID: string]: SessionStatus todo: Record<string, Todo[]>
} message: Record<string, Message[]>
session_diff: { part: Record<string, Part[]>
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
lsp: LspStatus[] lsp: LspStatus[]
mcp: { mcp: Record<string, McpStatus>
[key: string]: McpStatus mcp_resource: Record<string, McpResource>
}
mcp_resource: {
[key: string]: McpResource
}
formatter: FormatterStatus[] formatter: FormatterStatus[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
}>({ }>({
status: "complete",
provider: [],
provider_default: {},
provider_next: { provider_next: {
all: [], all: [],
default: {}, default: {},
@@ -114,16 +75,12 @@ export const {
experimentalBackgroundSubagents: false, experimentalBackgroundSubagents: false,
}, },
provider_auth: {}, provider_auth: {},
config: {},
status: "loading",
agent: [], agent: [],
command: [],
permission: {}, permission: {},
question: {}, question: {},
command: [], config: {},
provider: [],
provider_default: {},
session: [], session: [],
session_status: {},
session_diff: {}, session_diff: {},
todo: {}, todo: {},
message: {}, message: {},
@@ -135,521 +92,32 @@ export const {
vcs: undefined, vcs: undefined,
}) })
const event = useEvent() return {
const project = useProject()
const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
const touchMessage = (sessionID: string, messageID: string) => {
hydratingSessions.get(sessionID)?.messages.add(messageID)
}
const touchPart = (sessionID: string, partID: string) => {
hydratingSessions.get(sessionID)?.parts.add(partID)
}
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event, { workspace }) => {
switch (event.type) {
case "server.instance.disposed":
void bootstrap()
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const requests = store.question[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "question.asked": {
const request = event.properties
const requests = store.question[request.sessionID]
if (!requests) {
setStore("question", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("question", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"question",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "session.deleted": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "session.updated": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.next.moved": {
const result = search(store.session, event.properties.sessionID, (s) => s.id)
if (!result.found) break
setStore(
"session",
result.index,
produce((session) => {
session.directory = event.properties.location.directory
session.path = event.properties.subdirectory
session.workspaceID = event.properties.location.workspaceID
session.time.updated = event.properties.timestamp
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
touchMessage(event.properties.info.sessionID, event.properties.info.id)
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
touchMessage(event.properties.sessionID, event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
touchPart(event.properties.part.sessionID, event.properties.part.id)
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
case "message.part.delta": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = search(parts, event.properties.partID, (p) => p.id)
if (!result.found) break
touchPart(event.properties.sessionID, event.properties.partID)
setStore(
"part",
event.properties.messageID,
produce((draft) => {
const part = draft[result.index]
const field = event.properties.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + event.properties.delta
}),
)
break
}
case "message.part.removed": {
touchPart(event.properties.sessionID, event.properties.partID)
const parts = store.part[event.properties.messageID]
const result = search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "lsp.updated": {
const workspace = project.workspace.current()
void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
break
}
case "vcs.branch.updated": {
if (workspace === project.workspace.current()) {
setStore("vcs", { branch: event.properties.branch })
}
break
}
}
})
const exit = useExit()
const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const capabilitiesPromise = sdk.client.experimental.capabilities
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => undefined)
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
await Promise.all([
providersPromise,
providerListPromise,
capabilitiesPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
])
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const capabilitiesResponse = capabilitiesPromise
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
return Promise.all([
providersResponse,
providerListResponse,
capabilitiesResponse,
consoleStateResponse,
agentsResponse,
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const capabilities = responses[2]
const consoleState = responses[3]
const agents = responses[4]
const config = responses[5]
const sessions = responses[6]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions))
})
})
})
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
void Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data ?? {}))
}),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
setStore("status", "complete")
})
})
.catch(async (e) => {
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) {
exit(e)
} else {
throw e
}
})
}
onMount(() => {
void bootstrap()
})
const result = {
data: store, data: store,
set: setStore, set: setStore,
get status() { get status() {
return store.status return store.status
}, },
get ready() { get ready() {
if (startup.skipInitialLoading) return true return true
return store.status !== "loading"
}, },
get path() { get path() {
return project.instance.path() return project.instance.path()
}, },
session: { session: {
get(sessionID: string) { get(_sessionID: string) {
const match = search(store.session, sessionID, (s) => s.id) return undefined as Session | undefined
if (match.found) return store.session[match.index]
return undefined
}, },
query() { query() {
return sessionListQuery() return {} as { scope?: "project"; path?: string }
}, },
async refresh() { async refresh() {},
const list = await listSessions() status(_sessionID: string) {
setStore("session", reconcile(list)) return "idle" as const
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
if (session.time.compacting) return "compacting"
const messages = store.message[sessionID] ?? []
const last = messages.at(-1)
if (!last) return "idle"
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const syncing = syncingSessions.get(sessionID)
if (syncing) return syncing
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
hydratingSessions.set(sessionID, tracker)
const task = (async () => {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const currentMessages = draft.message[sessionID] ?? []
const infos = (messages.data ?? []).flatMap((message) => {
if (!tracker.messages.has(message.info.id)) return [message.info]
const current = currentMessages.find((item) => item.id === message.info.id)
return current ? [current] : []
})
infos.push(
...currentMessages.filter(
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
),
)
const removed = infos.slice(0, -100)
const visible = infos.slice(-100)
const visibleIDs = new Set(visible.map((message) => message.id))
for (const message of messages.data ?? []) {
if (!visibleIDs.has(message.info.id)) {
delete draft.part[message.info.id]
continue
}
const currentParts = draft.part[message.info.id] ?? []
const parts = message.parts.flatMap((part) => {
const current = currentParts.find((item) => item.id === part.id)
if (tracker.parts.has(part.id)) return current ? [current] : []
if (
current &&
(part.type === "text" || part.type === "reasoning") &&
(current.type === "text" || current.type === "reasoning") &&
part.text.length === 0 &&
current.text.length > 0
) {
return [current]
}
return [part]
})
parts.push(
...currentParts.filter(
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
),
)
draft.part[message.info.id] = parts
}
for (const message of removed) delete draft.part[message.id]
draft.message[sessionID] = visible
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
})().finally(() => {
syncingSessions.delete(sessionID)
hydratingSessions.delete(sessionID)
})
syncingSessions.set(sessionID, task)
return task
}, },
async sync(_sessionID: string) {},
}, },
bootstrap, async bootstrap(_input: { fatal?: boolean } = {}) {},
} }
return result
}, },
}) })
+43 -2
View File
@@ -1,4 +1,5 @@
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime"
import HomeFooter from "./home/footer" import HomeFooter from "./home/footer"
import HomeTips from "./home/tips" import HomeTips from "./home/tips"
import SidebarContext from "./sidebar/context" import SidebarContext from "./sidebar/context"
@@ -11,6 +12,7 @@ import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications" import Notifications from "./system/notifications"
import PluginManager from "./system/plugins" import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key" import WhichKey from "./system/which-key"
import Scrap from "./system/scrap"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & { export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string id: string
@@ -18,7 +20,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean enabled?: boolean
} }
export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] { export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [ return [
HomeFooter, HomeFooter,
HomeTips, HomeTips,
@@ -31,6 +33,45 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
Notifications, Notifications,
PluginManager, PluginManager,
WhichKey, WhichKey,
Scrap,
DiffViewer, DiffViewer,
] ]
} }
export async function loadBuiltinPlugins(
api: TuiPluginApi,
runtime: PluginRuntime,
) {
const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = []
for (const plugin of createBuiltinPlugins()) {
if (plugin.enabled === false) continue
const scoped = Object.assign(Object.create(api), {
slots: {
register(input: Parameters<typeof slots.register>[0]) {
dispose.push(slots.register({ ...input, id: plugin.id }))
return plugin.id
},
},
}) as TuiPluginApi
const now = Date.now()
await plugin.tui(scoped, undefined, {
id: plugin.id,
source: "internal",
spec: plugin.id,
target: plugin.id,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: plugin.id,
state: "first",
})
}
return () => {
for (const fn of dispose.reverse()) fn()
slots.dispose()
}
}
@@ -1,10 +1,10 @@
import type { Event } from "@opencode-ai/sdk/v2" import type { V2Event } from "@opencode-ai/sdk/v2"
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins" import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:notifications" const id = "internal:notifications"
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"] type SessionError = Extract<V2Event, { type: "session.error" }>["data"]["error"]
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
const session = sessionID ? api.state.session.get(sessionID) : undefined const session = sessionID ? api.state.session.get(sessionID) : undefined
@@ -33,38 +33,35 @@ const tui: TuiPlugin = async (api) => {
const permissions = new Set<string>() const permissions = new Set<string>()
api.event.on("question.asked", (event) => { api.event.on("question.asked", (event) => {
if (questions.has(event.properties.id)) return if (questions.has(event.data.id)) return
questions.add(event.properties.id) questions.add(event.data.id)
notify(api, event.properties.sessionID, "Question needs input", "question") notify(api, event.data.sessionID, "Question needs input", "question")
}) })
api.event.on("question.replied", (event) => { api.event.on("question.replied", (event) => {
questions.delete(event.properties.requestID) questions.delete(event.data.requestID)
}) })
api.event.on("question.rejected", (event) => { api.event.on("question.rejected", (event) => {
questions.delete(event.properties.requestID) questions.delete(event.data.requestID)
}) })
api.event.on("permission.asked", (event) => { api.event.on("permission.asked", (event) => {
if (permissions.has(event.properties.id)) return if (permissions.has(event.data.id)) return
permissions.add(event.properties.id) permissions.add(event.data.id)
notify(api, event.properties.sessionID, "Permission needs input", "permission") notify(api, event.data.sessionID, "Permission needs input", "permission")
}) })
api.event.on("permission.replied", (event) => { api.event.on("permission.replied", (event) => {
permissions.delete(event.properties.requestID) permissions.delete(event.data.requestID)
}) })
api.event.on("session.status", (event) => { const started = (sessionID: string) => {
const sessionID = event.properties.sessionID active.add(sessionID)
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { errored.delete(sessionID)
active.add(sessionID) }
errored.delete(sessionID)
return
}
if (event.properties.status.type !== "idle") return const ended = (sessionID: string) => {
if (!active.has(sessionID)) return if (!active.has(sessionID)) return
active.delete(sessionID) active.delete(sessionID)
@@ -75,14 +72,32 @@ const tui: TuiPlugin = async (api) => {
const session = api.state.session.get(sessionID) const session = api.state.session.get(sessionID)
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
api.event.on("session.next.prompted", (event) => started(event.data.sessionID))
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID))
api.event.on("session.next.step.started", (event) => started(event.data.sessionID))
api.event.on("session.next.retried", (event) => started(event.data.sessionID))
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID))
api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID))
api.event.on("session.next.step.ended", (event) => {
if (event.data.finish === "tool-calls") return
ended(event.data.sessionID)
})
api.event.on("session.next.step.failed", (event) => {
const sessionID = event.data.sessionID
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, "Session error", "error")
ended(sessionID)
}) })
api.event.on("session.error", (event) => { api.event.on("session.error", (event) => {
const sessionID = event.properties.sessionID const sessionID = event.data.sessionID
if (!sessionID) return if (!sessionID) return
if (!active.has(sessionID)) return if (!active.has(sessionID)) return
errored.add(sessionID) errored.add(sessionID)
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error") notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
}) })
} }
@@ -0,0 +1,66 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { useBindings } from "../../keymap"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:scrap"
const route = "scrap"
function Scrap(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
useBindings(() => ({
bindings: [
{
key: "escape",
desc: "Back home",
group: "Scrap",
cmd() {
props.api.route.navigate("home")
},
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
<box flexGrow={1} />
<box
height={1}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>esc home</text>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([{ name: route, render: () => <Scrap api={api} /> }])
api.keymap.registerLayer({
commands: [
{
name: "app.scrap",
title: "Open scrap screen",
category: "Debug",
namespace: "palette",
run() {
api.route.navigate(route)
api.ui.dialog.clear()
},
},
],
})
}
const plugin: BuiltinTuiPlugin = { id, tui }
export default plugin
+5 -3
View File
@@ -4,6 +4,7 @@ import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route" import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk" import type { useSDK } from "../context/sdk"
import type { useSync } from "../context/sync" import type { useSync } from "../context/sync"
import type { useData } from "../context/data"
import type { useTheme } from "../context/theme" import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog" import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap" import type { useOpencodeKeymap } from "../keymap"
@@ -31,6 +32,7 @@ type Input = {
event: ReturnType<typeof useEvent> event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK> sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme> theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast> toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"] renderer: TuiPluginApi["renderer"]
@@ -95,7 +97,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
return (item: SelectOption<Value>) => cb(pickOption(item)) return (item: SelectOption<Value>) => cb(pickOption(item))
} }
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] { function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return { return {
get ready() { get ready() {
return sync.ready return sync.ready
@@ -135,7 +137,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.message[sessionID] ?? [] return sync.data.message[sessionID] ?? []
}, },
status(sessionID) { status(sessionID) {
return sync.data.session_status[sessionID] return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
}, },
permission(sessionID) { permission(sessionID) {
return sync.data.permission[sessionID] ?? [] return sync.data.permission[sessionID] ?? []
@@ -297,7 +299,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
return input.kv.ready return input.kv.ready
}, },
}, },
state: stateApi(input.sync), state: stateApi(input.sync, input.data),
get client() { get client() {
return input.sdk.client return input.sdk.client
}, },
@@ -1,22 +1,16 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useSync } from "../../context/sync" import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select" import { DialogSelect } from "../../ui/dialog-select"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useClipboard } from "../../context/clipboard" import { useClipboard } from "../../context/clipboard"
import type { PromptInfo } from "../../component/prompt/history" import { useToast } from "../../ui/toast"
import { stripPromptPartIDs as strip } from "../../prompt/part"
export function DialogMessage(props: { export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
messageID: string const data = useData()
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
const clipboard = useClipboard() const clipboard = useClipboard()
const toast = useToast()
const message = createMemo(() =>
data.session.message.get(props.sessionID, props.messageID),
)
return ( return (
<DialogSelect <DialogSelect
@@ -27,29 +21,7 @@ export function DialogMessage(props: {
value: "session.revert", value: "session.revert",
description: "undo messages and file changes", description: "undo messages and file changes",
onSelect: (dialog) => { onSelect: (dialog) => {
const msg = message() toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
if (!msg) return
void sdk.client.session.revert({
sessionID: props.sessionID,
messageID: msg.id,
})
if (props.setPrompt) {
const parts = sync.data.part[msg.id]
const promptInfo = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
props.setPrompt(promptInfo)
}
dialog.clear() dialog.clear()
}, },
}, },
@@ -58,17 +30,19 @@ export function DialogMessage(props: {
value: "message.copy", value: "message.copy",
description: "message text to clipboard", description: "message text to clipboard",
onSelect: async (dialog) => { onSelect: async (dialog) => {
const msg = message() const value = message()
if (!msg) return if (!value) return
const text =
const parts = sync.data.part[msg.id] value.type === "user"
const text = parts.reduce((agg, part) => { ? value.text
if (part.type === "text" && !part.synthetic) { : value.type === "assistant"
agg += part.text ? value.content
} .filter((content) => content.type === "text")
return agg .map((content) => content.text)
}, "") .join("\n")
: "text" in value
? value.text
: ""
await clipboard.write?.(text) await clipboard.write?.(text)
dialog.clear() dialog.clear()
}, },
@@ -77,29 +51,8 @@ export function DialogMessage(props: {
title: "Fork", title: "Fork",
value: "session.fork", value: "session.fork",
description: "create a new session", description: "create a new session",
onSelect: async (dialog) => { onSelect: (dialog) => {
const result = await sdk.client.session.fork({ toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
sessionID: props.sessionID,
messageID: props.messageID,
})
const msg = message()
const prompt = msg
? sync.data.part[msg.id].reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
: undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
prompt,
})
dialog.clear() dialog.clear()
}, },
}, },
File diff suppressed because it is too large Load Diff
+73 -52
View File
@@ -4,11 +4,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2" import type { PermissionV2Request } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useSync } from "../../context/sync" import { useData } from "../../context/data"
import { useProject } from "../../context/project"
import { filetype } from "../../util/filetype" import { filetype } from "../../util/filetype"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display" import { webSearchProviderLabel } from "../../util/tool-display"
@@ -19,7 +18,7 @@ import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject" type PermissionStage = "permission" | "always" | "reject"
function EditBody(props: { request: PermissionRequest }) { function EditBody(props: { request: PermissionV2Request; patch?: string }) {
const themeState = useTheme() const themeState = useTheme()
const theme = themeState.theme const theme = themeState.theme
const syntax = themeState.syntax const syntax = themeState.syntax
@@ -27,8 +26,7 @@ function EditBody(props: { request: PermissionRequest }) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const filepath = createMemo(() => { const filepath = createMemo(() => {
const value = props.request.metadata?.filepath return props.request.resources[0] ?? ""
return typeof value === "string" ? value : ""
}) })
const diff = createMemo(() => { const diff = createMemo(() => {
const value = props.request.metadata?.diff const value = props.request.metadata?.diff
@@ -79,9 +77,36 @@ function EditBody(props: { request: PermissionRequest }) {
</scrollbox> </scrollbox>
</Show> </Show>
<Show when={!diff()}> <Show when={!diff()}>
<box paddingLeft={1}> <Show
<text fg={theme.textMuted}>No diff provided</text> when={props.patch}
</box> fallback={
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
</box>
}
>
{(patch) => (
<scrollbox
height="100%"
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<code
filetype="diff"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={patch()}
fg={theme.textMuted}
/>
</scrollbox>
)}
</Show>
</Show> </Show>
</box> </box>
) )
@@ -108,26 +133,22 @@ function TextBody(props: { title: string; description?: string; icon?: string })
) )
} }
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) { export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
const sdk = useSDK() const sdk = useSDK()
const project = useProject() const data = useData()
const sync = useSync()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
stage: "permission" as PermissionStage, stage: "permission" as PermissionStage,
}) })
const pathFormatter = usePathFormatter() const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
const input = createMemo(() => { const input = createMemo(() => {
const tool = props.request.tool const tool = props.request.source
if (!tool) return {} if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? [] const message = data.session.message.get(props.request.sessionID, tool.messageID)
for (const part of parts) { if (message?.type !== "assistant") return {}
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") { const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
return part.state.input ?? {} if (part?.type === "tool" && part.state.status !== "pending") return part.state.input
}
}
return {} return {}
}) })
@@ -140,14 +161,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
title="Always allow" title="Always allow"
body={ body={
<Switch> <Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}> <Match when={props.request.save?.length === 1 && props.request.save[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} /> <TextBody title={"This will allow " + props.request.action + " until OpenCode is restarted."} />
</Match> </Match>
<Match when={true}> <Match when={true}>
<box paddingLeft={1} gap={1}> <box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text> <text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box> <box>
<For each={props.request.always}> <For each={props.request.save ?? []}>
{(pattern) => ( {(pattern) => (
<text fg={theme.text}> <text fg={theme.text}>
{"- "} {"- "}
@@ -165,11 +186,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
onSelect={(option) => { onSelect={(option) => {
setStore("stage", "permission") setStore("stage", "permission")
if (option === "cancel") return if (option === "cancel") return
void sdk.client.permission.reply({ void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "always", reply: "always",
requestID: props.request.id, requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
}) })
}} }}
/> />
@@ -177,12 +197,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<Match when={store.stage === "reject"}> <Match when={store.stage === "reject"}>
<RejectPrompt <RejectPrompt
onConfirm={(message) => { onConfirm={(message) => {
void sdk.client.permission.reply({ void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "reject", reply: "reject",
requestID: props.request.id, requestID: props.request.id,
directory: props.directory,
message: message || undefined, message: message || undefined,
workspace: project.workspace.current(),
}) })
}} }}
onCancel={() => { onCancel={() => {
@@ -193,21 +212,21 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<Match when={store.stage === "permission"}> <Match when={store.stage === "permission"}>
{(() => { {(() => {
const info = () => { const info = () => {
const permission = props.request.permission const permission = props.request.action
const data = input() const data = input()
if (permission === "edit") { if (permission === "edit") {
const raw = props.request.metadata?.filepath const filepath = props.request.resources[0] ?? ""
const filepath = typeof raw === "string" ? raw : "" const patch = typeof data.patchText === "string" ? data.patchText : undefined
return { return {
icon: "→", icon: "→",
title: `Edit ${pathFormatter.format(filepath)}`, title: `Edit ${pathFormatter.format(filepath)}`,
body: <EditBody request={props.request} />, body: <EditBody request={props.request} patch={patch} />,
} }
} }
if (permission === "read") { if (permission === "read") {
const raw = data.filePath const raw = data.path
const filePath = typeof raw === "string" ? raw : "" const filePath = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
@@ -271,8 +290,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
if (permission === "bash") { if (permission === "bash") {
const command = typeof data.command === "string" ? data.command : "" const command = typeof data.command === "string" ? data.command : ""
return { return {
icon: "#",
title: "Shell command",
body: ( body: (
<Show when={command}> <Show when={command}>
<box paddingLeft={1}> <box paddingLeft={1}>
@@ -333,13 +350,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
const meta = props.request.metadata ?? {} const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0] const pattern = props.request.resources[0]
const derived = const derived =
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived const raw = parent ?? filepath ?? derived
const dir = pathFormatter.format(raw) const dir = pathFormatter.format(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") const patterns = props.request.resources.filter((p): p is string => typeof p === "string")
return { return {
icon: "←", icon: "←",
@@ -388,12 +405,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<text fg={theme.warning}>{"△"}</text> <text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text> <text fg={theme.text}>Permission required</text>
</box> </box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}> <Show when={current.title}>
<text fg={theme.textMuted} flexShrink={0}> <box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
{current.icon} <text fg={theme.textMuted} flexShrink={0}>
</text> {current.icon}
<text fg={theme.text}>{current.title}</text> </text>
</box> <text fg={theme.text}>{current.title}</text>
</box>
</Show>
</box> </box>
) )
@@ -402,7 +421,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
title="Permission required" title="Permission required"
header={header()} header={header()}
body={current.body} body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} options={
props.request.save?.length
? { once: "Allow once", always: "Allow always", reject: "Reject" }
: { once: "Allow once", reject: "Reject" }
}
escapeKey="reject" escapeKey="reject"
fullscreen fullscreen
onSelect={(option) => { onSelect={(option) => {
@@ -415,19 +438,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
setStore("stage", "reject") setStore("stage", "reject")
return return
} }
void sdk.client.permission.reply({ void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "reject", reply: "reject",
requestID: props.request.id, requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
}) })
return return
} }
void sdk.client.permission.reply({ void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "once", reply: "once",
requestID: props.request.id, requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
}) })
}} }}
/> />
+11 -11
View File
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme" import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
@@ -11,7 +11,7 @@ import { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question" const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) { export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
const sdk = useSDK() const sdk = useSDK()
const { theme } = useTheme() const { theme } = useTheme()
const renderer = useRenderer() const renderer = useRenderer()
@@ -24,7 +24,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null) const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
tab: 0, tab: 0,
answers: [] as QuestionAnswer[], answers: [] as QuestionV2Answer[],
custom: [] as string[], custom: [] as string[],
selected: 0, selected: 0,
editing: false, editing: false,
@@ -47,17 +47,17 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
function submit() { function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? []) const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.client.question.reply({ void sdk.client.v2.session.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id, requestID: props.request.id,
directory: props.directory, questionV2Reply: { answers },
answers,
}) })
} }
function reject() { function reject() {
void sdk.client.question.reject({ void sdk.client.v2.session.question.reject({
sessionID: props.request.sessionID,
requestID: props.request.id, requestID: props.request.id,
directory: props.directory,
}) })
} }
@@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
setStore("custom", inputs) setStore("custom", inputs)
} }
if (single()) { if (single()) {
void sdk.client.question.reply({ void sdk.client.v2.session.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id, requestID: props.request.id,
directory: props.directory, questionV2Reply: { answers: [[answer]] },
answers: [[answer]],
}) })
return return
} }
+198
View File
@@ -0,0 +1,198 @@
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useData } from "../../context/data"
export type PartRef = {
messageID: string
partID: string
}
export type SessionRow =
| { type: "message"; messageID: string }
| { type: "part"; ref: PartRef }
| {
type: "group"
kind: "exploration"
refs: PartRef[]
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
const [rows, setRows] = createStore<SessionRow[]>([])
createEffect(() => {
const pending = new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
request.source?.type === "tool" ? [request.source.callID] : [],
),
)
setRows(
produce((draft) => {
draft.forEach((row) => {
if (row.type !== "group") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
}),
)
})
createEffect(
on(sessionID, (id) => {
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
void data.session.message.refresh(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
},
() => undefined,
)
}),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
completePrevious(draft)
draft.push({ type: "message", messageID })
}),
)
const appendPart = (ref: PartRef, name?: string) =>
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
if (name && exploration(name)) {
const previous = draft.at(-1)
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(draft)
draft.push({
type: "group",
kind: "exploration",
refs: [ref],
pending: [],
completed: false,
})
return
}
completePrevious(draft)
draft.push({ type: "part", ref })
}),
)
const appendFooter = (messageID: string) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
completePrevious(draft)
draft.push({ type: "assistant-footer", messageID })
}),
)
const message = (event: { data: { sessionID: string; messageID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
}
const subscriptions = [
data.on("session.next.prompted", message),
data.on("session.next.context.updated", message),
data.on("session.next.synthetic", message),
data.on("session.next.shell.started", message),
data.on("session.next.agent.switched", message),
data.on("session.next.model.switched", message),
data.on("session.next.compaction.ended", message),
data.on("session.next.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}),
data.on("session.next.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}),
data.on("session.next.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}),
data.on("session.next.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}),
data.on("session.next.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
}),
data.on("session.next.step.ended", (event) => {
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID)
}),
data.on("session.next.step.failed", (event) => {
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
return rows
}
export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
}
message.content.forEach((part) => {
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
append(rows, { messageID: message.id, partID: part.id }, part)
})
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
}
return rows
}, [])
}
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
if (part.type === "tool") {
if (exploration(part.name)) {
const previous = rows.at(-1)
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(rows)
rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
return
}
}
completePrevious(rows)
rows.push({ type: "part", ref })
}
function completePrevious(rows: SessionRow[]) {
const previous = rows.at(-1)
if (previous?.type === "group") previous.completed = true
}
function exploration(name: string) {
return ["read", "glob", "grep"].includes(name.toLowerCase())
}
function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
return [...row.refs, ...row.pending].some(
(item) => item.messageID === ref.messageID && item.partID === ref.partID,
)
})
}

Some files were not shown because too many files have changed in this diff Show More