Compare commits

..

23 Commits

Author SHA1 Message Date
Dax Raad b08e3ef8de feat(tui): add child agent picker
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-27 03:24:24 +00: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
opencode-agent[bot] 0e2dd4ad15 chore: generate 2026-06-26 17:56:55 +00:00
Dax Raad 11d2f3e5f8 fix(llm): end reasoning before responses 2026-06-26 13:55:04 -04:00
Dax Raad 1ac6b4bec4 fix(core): authorize external read paths 2026-06-26 13:55:04 -04:00
opencode-agent[bot] 0befd9b049 chore: generate 2026-06-26 17:53:26 +00:00
Dax Raad 850a0dfe7c fix(core): resolve prompt attachment mime types 2026-06-26 13:51:00 -04:00
opencode-agent[bot] 18a419e634 chore: generate 2026-06-26 17:36:45 +00:00
Adam 82f47cb312 feat(stats): add clickable section headings (#34095) 2026-06-26 12:34:44 -05:00
Adam 7b1fe33ed3 fix(data): clarify unchanged rank 2026-06-26 12:29:29 -05:00
150 changed files with 4687 additions and 4011 deletions
+7 -2
View File
@@ -6,6 +6,7 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -31,6 +32,9 @@ permissions:
contents: write
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -122,7 +126,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -221,7 +225,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -447,6 +451,7 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
+2 -5
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- v2
pull_request:
workflow_dispatch:
@@ -74,13 +75,9 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
+3 -1
View File
@@ -93,7 +93,7 @@
"name": "@opencode-ai/cli",
"version": "1.17.11",
"bin": {
"lildax": "./bin/lildax.cjs",
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@effect/platform-node": "catalog:",
@@ -316,6 +316,7 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
@@ -934,6 +935,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module",
"packageManager": "bun@1.3.14",
"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:web": "bun --cwd packages/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 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 arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "lildax.exe" : "lildax"
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
function supportsAvx2() {
if (arch !== "x64") return false
@@ -121,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
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 ") +
" package",
)
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"license": "MIT",
"bin": {
"lildax": "./bin/lildax.cjs"
"opencode2": "./bin/opencode2.cjs"
},
"files": [
"bin"
+1 -1
View File
@@ -10,7 +10,7 @@ import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax"
const binary = "opencode2"
process.chdir(dir)
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)
const version = Object.values(binaries)[0]
const name = pkg.name
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${name}/package.json`).write(
JSON.stringify(
{
name: pkg.name,
bin: { lildax: "./bin/lildax" },
name,
bin: { opencode2: "./bin/opencode2" },
version,
license: pkg.license,
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),
),
)
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", {
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: [
Spec.make("api", {
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")),
port: Flag.integer("port").pipe(Flag.optional),
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 { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(Commands, () =>
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
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"))
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 { createServer } from "node:http"
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 { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
@@ -16,15 +18,41 @@ export default Runtime.handler(
return yield* Effect.scoped(
Effect.gen(function* () {
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)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
}),
const url = HttpServer.formatAddress(address)
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) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
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) {
const server = createServer()
return Layer.build(
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(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,
Effect.fn("cli.service.status")(function* () {
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 { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { Scope } from "effect"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +11,11 @@ export type Input<Value> =
? Input
: 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<{
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
? Loader<Node>
+12
View File
@@ -2,10 +2,19 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
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, {
$: () => import("./commands/handlers/default"),
@@ -25,8 +34,11 @@ const Handlers = Runtime.handlers(Commands, {
})
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain,
)
+24 -12
View File
@@ -1,5 +1,5 @@
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 { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
@@ -28,6 +28,10 @@ const Registration = Schema.Struct({
})
type Registration = typeof Registration.Type
const Config = Schema.Struct({
password: Schema.optional(Schema.String),
})
function sameRegistration(left: Registration, right: Registration) {
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* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
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 existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
const config = yield* fs
.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
// can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
const temp = configFile + ".tmp"
yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
return next
})
const registration = Effect.fnUntraced(function* () {
@@ -111,7 +123,7 @@ export const layer = Layer.effect(
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
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)
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 { Effect } from "effect"
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 })
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
const options = { baseUrl: transport.url, headers: transport.headers }
const client = createOpencodeClient(options)
const directory = yield* Effect.tryPromise(() =>
client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
).pipe(
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))
}
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
}
}
+2 -4
View File
@@ -305,7 +305,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@@ -324,7 +323,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@@ -343,7 +341,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@@ -362,7 +359,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@@ -514,6 +510,7 @@ export type SessionsContextOutput = {
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
@@ -1131,6 +1128,7 @@ export type SessionsMessageOutput = {
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
+1
View File
@@ -102,6 +102,7 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
-1
View File
@@ -48,7 +48,6 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_TAB_SCROLL: enabledByExperimental("OPENCODE_EXPERIMENTAL_TAB_SCROLL"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+17 -14
View File
@@ -23,6 +23,7 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
import { State } from "../state"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
@@ -102,20 +103,22 @@ export const locationLayer = Layer.effectDiscard(
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
yield* Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
yield* State.batch(
Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
).pipe(
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 { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
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) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
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)" } })
})
yield* load()
connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
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(
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 }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
})
+19
View File
@@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
}
export interface RunOptions {
readonly combineOutput?: boolean
readonly maxOutputBytes?: number
readonly maxErrorBytes?: number
readonly signal?: AbortSignal
@@ -37,8 +38,10 @@ export interface RunStreamOptions {
export interface RunResult {
readonly command: string
readonly exitCode: number
readonly output?: Buffer
readonly stdout: Buffer
readonly stderr: Buffer
readonly outputTruncated?: boolean
readonly stdoutTruncated: boolean
readonly stderrTruncated: boolean
}
@@ -143,6 +146,22 @@ export const layer = Layer.effect(
const collect = Effect.scoped(
Effect.gen(function* () {
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(
[
collectStream(handle.stdout, options?.maxOutputBytes),
+5 -1
View File
@@ -1,3 +1,7 @@
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)
+27 -4
View File
@@ -10,6 +10,7 @@ import { ModelV2 } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { EventV2 } from "./event"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
@@ -32,6 +33,7 @@ import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -137,7 +139,7 @@ export interface Interface {
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
prompt: Prompt
prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
@@ -349,13 +351,14 @@ export const layer = Layer.unwrap(
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt: input.prompt,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
@@ -387,7 +390,13 @@ export const layer = Layer.unwrap(
})
}),
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, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
@@ -452,3 +461,17 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
return {
...file,
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
}
}),
})
@@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID,
text: "",
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)
if (match) {
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
}
})
+2 -1
View File
@@ -333,7 +333,8 @@ export const layer = Layer.effect(
if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
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 }
}),
)
+1 -1
View File
@@ -132,7 +132,7 @@ export const fromCatalogModel = (
credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.metadata === undefined
credential?.type !== "key" || credential.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata)
+45 -11
View File
@@ -1,6 +1,8 @@
export * as ApplyPatchTool from "./apply-patch"
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 { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
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 const toModelOutput = (output: Output) =>
@@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
].join("\n")
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" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly content: string
readonly before: string
readonly after: string
})
export const layer = Layer.effectDiscard(
@@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
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
}
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") {
prepared.push({ ...hunk, target })
prepared.push({ ...hunk, target, before, after: "" })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
const update = Patch.derive(hunk.path, hunk.chunks, original)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile)
yield* Effect.forEach(
prepared,
(change) =>
@@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied }
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
@@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
.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({
command: Schema.String,
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
output: Schema.String,
const StructuredOutput = Schema.Struct({
exit: Schema.Number.pipe(Schema.optional),
truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional),
timeout: Schema.Boolean.pipe(Schema.optional),
})
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
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 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 warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
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.`,
input: Input,
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) =>
Effect.gen(function* () {
const source = {
@@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard(
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
combineOutput: true,
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
@@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard(
)
if (!result) {
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.`,
truncated: false,
timedOut: true,
timeout: true,
...(warnings.length ? { warnings } : {}),
}
}
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
const output = result.output?.toString("utf8") || "(no output)"
const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
return {
command: input.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
exit: result.exitCode,
output: notice ? `${output}\n\n${notice}` : output,
truncated: result.outputTruncated === true,
...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
}
}).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"
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 { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -30,10 +32,7 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
replacements: Schema.Number,
})
export type Output = typeof Output.Type
@@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.resource}`,
`Edited file successfully: ${output.files[0]?.file}`,
`Replacements: ${output.replacements}`,
"```diff",
...previewLines(oldString, "-"),
@@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
input.replaceAll === true
? source.text.replaceAll(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 result = yield* unableToEdit(
files.writeIfUnchanged({
@@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
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
})
},
}),
+24 -20
View File
@@ -1,12 +1,10 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
@@ -30,9 +28,8 @@ const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, Re
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const fs = yield* FSUtil.Service
const reader = yield* ReadToolFileSystem.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
@@ -40,7 +37,7 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => {
@@ -53,27 +50,34 @@ export const layer = Layer.effectDiscard(
},
execute: (input, context) => {
return Effect.gen(function* () {
const absolute = path.resolve(location.directory, input.path)
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute)
const root = yield* fs.realPath(selected)
if (!FSUtil.contains(root, real))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
const target = AbsolutePath.make(real)
const type = yield* reader.inspect(target)
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
const type = yield* reader.inspect(absolute)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source,
})
if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(target, resource, {
if (type === "directory")
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
+26 -8
View File
@@ -37,10 +37,19 @@ export type Content =
| { readonly type: "text"; readonly text: 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 input: Input
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: (
input: Schema.Schema.Type<Input>,
context: Context,
@@ -59,10 +68,12 @@ type Runtime = {
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
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>()
runtimes.set(tool, {
definition: (name) => {
@@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
outputSchema: toJsonSchema(config.structured ?? config.output),
})
definitions.set(name, definition)
return definition
@@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
config.execute(input, context).pipe(
Effect.flatMap((output) =>
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(
(error) =>
new ToolFailure({
@@ -92,8 +110,8 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
),
),
),
Effect.map((output) => ({
structured: output,
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
@@ -42,6 +42,15 @@ const openai: Lowerer = {
},
request(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) {
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
delete result.text_verbosity
+2 -2
View File
@@ -601,9 +601,9 @@ describe("Config", () => {
models: {
model: {
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(
lowerer.request({
reasoningEffort: "high",
reasoningSummary: "auto",
reasoning: { encryptedContent: true },
textVerbosity: "low",
text: { outputFormat: "plain" },
nestedValue: { camelCase: true },
}),
).toEqual({
reasoning_effort: "high",
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
text: { output_format: "plain", verbosity: "low" },
nested_value: { camel_case: true },
})
@@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
body: { trace: true },
settings: { resourceName: "resource" },
})
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
reasoning_effort: "high",
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
reasoning: { effort: "high", summary: "auto" },
text: { verbosity: "low" },
})
})
@@ -25,6 +25,20 @@ function required<T>(value: T | undefined): T {
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>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -67,11 +81,14 @@ describe("OpencodePlugin", () => {
Effect.acquireUseRelease(
Effect.sync(() => {
const authorization: Array<string | null> = []
const gate = Promise.withResolvers<void>()
return {
authorization,
release: gate.resolve,
server: Bun.serve({
port: 0,
fetch: (request) => {
fetch: async (request) => {
await gate.promise
authorization.push(request.headers.get("authorization"))
const origin = new URL(request.url).origin
return Response.json({
@@ -110,7 +127,7 @@ describe("OpencodePlugin", () => {
}),
}
}),
({ authorization, server }) =>
({ authorization, release, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
@@ -128,8 +145,15 @@ describe("OpencodePlugin", () => {
})
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({
name: "Remote",
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(
"non-zero exit returns RunResult; caller can require success",
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* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
@@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
expect(
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 })
}),
)
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", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
+21
View File
@@ -173,6 +173,27 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("resolves attachment MIME before admission", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: {
text: "Inspect this image",
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
},
resume: false,
})
expect(message.prompt.files).toEqual([
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
}),
)
it.effect("streams durable Session events after an aggregate sequence", () =>
Effect.gen(function* () {
yield* setup
@@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
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", () =>
Effect.gen(function* () {
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* () {
yield* setup
const session = yield* SessionV2.Service
@@ -2579,11 +2579,20 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, 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([
{ 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: "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([
{ 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 = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
@@ -83,8 +85,10 @@ const reset = () => {
result = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
@@ -135,24 +139,33 @@ describe("BashTool", () => {
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
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* 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: {
structured: {
command: "pwd",
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "hello\n",
exit: 0,
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[0]?.options).toMatchObject({
combineOutput: true,
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
@@ -222,13 +235,17 @@ describe("BashTool", () => {
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
expect(settled.output?.structured).toMatchObject({
command: "printf core-bash",
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "core-bash",
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "core-bash" },
{ type: "text", text: "Command exited with code 0." },
],
})
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(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({
warnings: [
`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.`,
],
truncated: false,
})
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()),
(tmp) => {
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(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
expect(settled.output?.content[1]).toMatchObject({
type: "text",
value: expect.stringContaining("Command exited with code 7"),
text: expect.stringContaining("Command exited with code 7"),
})
expect(settled.output?.structured).toMatchObject({
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
output: "HEAD full output TAIL",
exit: 7,
truncated: false,
})
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
}),
),
)
@@ -352,14 +369,14 @@ describe("BashTool", () => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, stdoutTruncated: true }
result = { ...result, outputTruncated: true }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
expect(settled.result).toMatchObject({
expect(settled.output?.structured).toMatchObject({ truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
value: expect.stringContaining("stdout capture truncated"),
text: expect.stringContaining("output capture truncated"),
})
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(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
expect(settled.output?.content[1]).toMatchObject({
type: "text",
value: expect.stringContaining("Command timed out"),
text: expect.stringContaining("Command timed out"),
})
expect(settled.output?.structured).toMatchObject({
command: "sleep 60",
timedOut: true,
timeout: true,
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```",
})
expect(settled.output?.structured).toEqual({
operation: "write",
target: yield* Effect.promise(() => fs.realpath(target)),
resource: "hello.txt",
existed: true,
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(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
+55 -2
View File
@@ -11,6 +11,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
@@ -97,6 +98,32 @@ const infrastructure = Layer.mergeAll(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
)
const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
if (input.path === missingPath)
return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
const canonical = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
const directory = path.dirname(canonical)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
canonical,
resource,
externalDirectory: external
? {
action: "external_directory" as const,
directory,
resource: externalResource,
save: externalResource,
}
: undefined,
})
},
}),
)
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
@@ -107,19 +134,21 @@ const read = ReadTool.layer.pipe(
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
)
const sessionID = SessionV2.ID.make("ses_read_tool_test")
@@ -174,6 +203,30 @@ describe("ReadTool", () => {
}),
)
it.effect("asks for external_directory approval before reading an external absolute path", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
expect(assertions).toMatchObject([
{
sessionID,
action: "external_directory",
resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
},
{ sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
])
expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }])
}),
)
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+23 -9
View File
@@ -407,21 +407,35 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
lifecycle = part.thought
? Lifecycle.reasoningDelta(
lifecycle,
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
lifecycle,
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
continue
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
LLMEvent.toolCall({
+6 -1
View File
@@ -411,7 +411,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart(
+4 -1
View File
@@ -347,10 +347,10 @@ describe("Gemini route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
@@ -399,6 +399,9 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
)
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
@@ -542,9 +542,9 @@ describe("OpenAI Chat route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
+2 -3
View File
@@ -3,6 +3,7 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -132,7 +133,7 @@ export const AttachCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: args.url,
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
@@ -140,8 +141,6 @@ export const AttachCommand = cmd({
sessionID: args.session,
fork: args.fork,
},
directory,
headers,
}),
)
},
@@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
() => (
<box width="100%" height={1}>
<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.muted }}>
{" "}
@@ -10,7 +10,7 @@ export function turnSummaryCommit(input: {
}): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final",
source: "system",
summary: {
+4 -57
View File
@@ -8,8 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
@@ -18,36 +17,6 @@ declare global {
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() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
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 network = resolveNetworkOptionsNoConfig(args)
const external =
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),
}
const url = (await client.call("server", network)).url
try {
await validateSession({
url: transport.url,
url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
@@ -254,7 +204,7 @@ export const TuiThreadCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: transport.url,
client: createOpencodeClient({ baseUrl: url, directory: cwd }),
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -262,9 +212,6 @@ export const TuiThreadCommand = cmd({
},
config,
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
-26
View File
@@ -3,8 +3,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
@@ -20,33 +18,9 @@ const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
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
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() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
+2 -5
View File
@@ -1,10 +1,7 @@
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return createBuiltinPlugins({
experimentalEventSystem: flags.experimentalEventSystem,
})
export function internalTuiPlugins(): InternalTuiPlugin[] {
return createBuiltinPlugins()
}
+1 -1
View File
@@ -1089,7 +1089,7 @@ async function load(input: {
if (Flag.OPENCODE_PURE && pluginOrigins.length) {
}
for (const item of internalTuiPlugins(flags)) {
for (const item of internalTuiPlugins()) {
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
@@ -119,7 +119,7 @@ test("turn summary starts at the left edge", async () => {
const commits = claim(out.renderer)
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 {
destroy(commits)
}
@@ -279,7 +279,7 @@ describe("run session replay", () => {
}),
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.8s",
text: "Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
@@ -314,7 +314,7 @@ describe("run session replay", () => {
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "Build · Little Frank · 2.8s",
text: "Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
@@ -346,7 +346,7 @@ describe("run session replay", () => {
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.0s",
text: "Build · gpt-5 · 2.0s",
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 {
AgentPart,
OpencodeClient,
Event,
V2Event,
FilePart,
LspStatus,
McpStatus,
@@ -517,7 +517,10 @@ export type TuiSlots = {
}
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>
+2 -2
View File
@@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
@@ -192,7 +192,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
prompt: PromptInput.Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
+1
View File
@@ -24,4 +24,5 @@ export { PtyTicket } from "./pty-ticket"
export { Question } from "./question"
export { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export { PromptInput } from "./prompt-input"
export * from "./schema"
+26
View File
@@ -0,0 +1,26 @@
export * as PromptInput from "./prompt-input"
import { Schema } from "effect"
import { AgentAttachment, Source } from "./prompt"
import { optional, statics } from "./schema"
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
export const FileAttachment = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
source: Source.pipe(optional),
})
.annotate({ identifier: "PromptInput.FileAttachment" })
.pipe(
statics((schema) => ({
create: (input: FileAttachment) => schema.make(input),
})),
)
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Prompt = Schema.Struct({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(optional),
agents: Schema.Array(AgentAttachment).pipe(optional),
}).annotate({ identifier: "PromptInput" })
+4
View File
@@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+2 -2
View File
@@ -142,7 +142,7 @@ import type {
ProjectListResponses,
ProjectUpdateErrors,
ProjectUpdateResponses,
Prompt,
PromptInput,
ProviderAuthErrors,
ProviderAuthResponses,
ProviderListErrors,
@@ -5621,7 +5621,7 @@ export class Session3 extends HeyApiClient {
parameters: {
sessionID: string
id?: string
prompt?: Prompt
prompt?: PromptInput
delivery?: "steer" | "queue"
resume?: boolean
},
+18 -1
View File
@@ -2700,6 +2700,12 @@ export type SessionNotFoundError = {
message: string
}
export type PromptInput = {
text: string
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
}
export type ConflictError = {
_tag: "ConflictError"
message: string
@@ -3814,6 +3820,13 @@ export type SessionV2Info = {
revert?: RevertState
}
export type PromptInputFileAttachment = {
uri: string
name?: string
description?: string
source?: PromptSource
}
export type SessionInputAdmitted = {
admittedSeq: number
id: string
@@ -3913,6 +3926,10 @@ export type SessionMessageAssistantReasoning = {
id: string
text: string
providerMetadata?: LlmProviderMetadata
time?: {
created: number
completed?: number
}
}
export type SessionMessageToolStatePending = {
@@ -12091,7 +12108,7 @@ export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V
export type V2SessionPromptData = {
body: {
id?: string
prompt: Prompt
prompt: PromptInput
delivery?: "steer" | "queue"
resume?: boolean
}
+42 -1
View File
@@ -10594,7 +10594,7 @@
"pattern": "^msg_"
},
"prompt": {
"$ref": "#/components/schemas/Prompt"
"$ref": "#/components/schemas/PromptInput"
},
"delivery": {
"type": "string",
@@ -23533,6 +23533,28 @@
"required": ["_tag", "sessionID", "message"],
"additionalProperties": false
},
"PromptInput": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"files": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptInputFileAttachment"
}
},
"agents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptAgentAttachment"
}
}
},
"required": ["text"],
"additionalProperties": false
},
"ConflictError": {
"type": "object",
"properties": {
@@ -26957,6 +26979,25 @@
"required": ["id", "projectID", "cost", "tokens", "time", "title", "location"],
"additionalProperties": false
},
"PromptInputFileAttachment": {
"type": "object",
"properties": {
"uri": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/PromptSource"
}
},
"required": ["uri"],
"additionalProperties": false
},
"SessionInputAdmitted": {
"type": "object",
"properties": {
+11 -2
View File
@@ -1,16 +1,25 @@
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 { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { Api } from "../api"
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 {
_tag: "Event",
event: "message",
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">
<span
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">
<ToolStatusTitle
@@ -981,7 +985,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
</span>
<span
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
items={[
+1
View File
@@ -240,6 +240,7 @@ const en = {
"model.noPeersDescription": "Peer rankings appear after usage lands.",
"model.noUsageLastWeek": "No usage last week",
"model.newThisWeek": "New this week",
"model.sameAsPreviousWeek": "Same as previous week",
"model.vsPreviousWeek": "{{change}} vs previous week",
"model.pdf": "PDF",
"format.users": "users",
+1
View File
@@ -221,6 +221,7 @@ export const dict = {
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
"model.newThisWeek": "جديد هذا الأسبوع",
"model.sameAsPreviousWeek": "دون تغيير عن الأسبوع السابق",
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
"model.pdf": "PDF",
"format.users": "مستخدمون",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Os rankings de pares aparecem depois que o uso chega.",
"model.noUsageLastWeek": "Sem uso na semana passada",
"model.newThisWeek": "Novo esta semana",
"model.sameAsPreviousWeek": "Igual à semana anterior",
"model.vsPreviousWeek": "{{change}} vs semana anterior",
"model.pdf": "PDF",
"format.users": "usuários",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "Ranglister over lignende modeller vises, når brug lander.",
"model.noUsageLastWeek": "Ingen brug sidste uge",
"model.newThisWeek": "Ny denne uge",
"model.sameAsPreviousWeek": "Samme som forrige uge",
"model.vsPreviousWeek": "{{change}} vs forrige uge",
"model.pdf": "PDF",
"format.users": "brugere",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Vergleichsrankings erscheinen, nachdem Nutzung eingegangen ist.",
"model.noUsageLastWeek": "Keine Nutzung letzte Woche",
"model.newThisWeek": "Neu diese Woche",
"model.sameAsPreviousWeek": "Unverändert zur vorherigen Woche",
"model.vsPreviousWeek": "{{change}} ggü. vorheriger Woche",
"model.pdf": "PDF",
"format.users": "Nutzer",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "Las clasificaciones de modelos similares aparecen después de que llegue uso.",
"model.noUsageLastWeek": "Sin uso la semana pasada",
"model.newThisWeek": "Nuevo esta semana",
"model.sameAsPreviousWeek": "Igual que la semana anterior",
"model.vsPreviousWeek": "{{change}} vs semana anterior",
"model.pdf": "PDF",
"format.users": "usuarios",
+1
View File
@@ -224,6 +224,7 @@ export const dict = {
"model.noPeersDescription": "Les classements de modèles proches apparaissent après l'arrivée de l'utilisation.",
"model.noUsageLastWeek": "Aucune utilisation la semaine dernière",
"model.newThisWeek": "Nouveau cette semaine",
"model.sameAsPreviousWeek": "Identique à la semaine précédente",
"model.vsPreviousWeek": "{{change}} vs semaine précédente",
"model.pdf": "PDF",
"format.users": "utilisateurs",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Le classifiche dei modelli simili appaiono dopo l'arrivo dell'utilizzo.",
"model.noUsageLastWeek": "Nessun utilizzo la scorsa settimana",
"model.newThisWeek": "Nuovo questa settimana",
"model.sameAsPreviousWeek": "Uguale alla settimana precedente",
"model.vsPreviousWeek": "{{change}} vs settimana precedente",
"model.pdf": "PDF",
"format.users": "utenti",
+1
View File
@@ -224,6 +224,7 @@ export const dict = {
"model.noPeersDescription": "使用量が届くと類似モデルのランキングが表示されます。",
"model.noUsageLastWeek": "先週の使用量なし",
"model.newThisWeek": "今週新規",
"model.sameAsPreviousWeek": "前週と同じ",
"model.vsPreviousWeek": "前週比 {{change}}",
"model.pdf": "PDF",
"format.users": "ユーザー",
+1
View File
@@ -224,6 +224,7 @@ export const dict = {
"model.noPeersDescription": "사용량이 들어오면 비슷한 모델 순위가 표시됩니다.",
"model.noUsageLastWeek": "지난주 사용량 없음",
"model.newThisWeek": "이번 주 신규",
"model.sameAsPreviousWeek": "지난주와 동일",
"model.vsPreviousWeek": "지난주 대비 {{change}}",
"model.pdf": "PDF",
"format.users": "사용자",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "Rangeringer for lignende modeller vises etter at bruk lander.",
"model.noUsageLastWeek": "Ingen bruk forrige uke",
"model.newThisWeek": "Ny denne uken",
"model.sameAsPreviousWeek": "Samme som forrige uke",
"model.vsPreviousWeek": "{{change}} mot forrige uke",
"model.pdf": "PDF",
"format.users": "brukere",
+1
View File
@@ -221,6 +221,7 @@ export const dict = {
"model.noPeersDescription": "Rankingi podobnych modeli pojawią się po nadejściu użycia.",
"model.noUsageLastWeek": "Brak użycia w zeszłym tygodniu",
"model.newThisWeek": "Nowy w tym tygodniu",
"model.sameAsPreviousWeek": "Tak samo jak w poprzednim tygodniu",
"model.vsPreviousWeek": "{{change}} vs poprzedni tydzień",
"model.pdf": "PDF",
"format.users": "użytkownicy",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Рейтинги похожих моделей появятся после использования.",
"model.noUsageLastWeek": "Нет использования на прошлой неделе",
"model.newThisWeek": "Новая на этой неделе",
"model.sameAsPreviousWeek": "Без изменений к предыдущей неделе",
"model.vsPreviousWeek": "{{change}} к предыдущей неделе",
"model.pdf": "PDF",
"format.users": "пользователи",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "อันดับโมเดลใกล้เคียงจะแสดงหลังจากมีการใช้งานเข้ามา",
"model.noUsageLastWeek": "ไม่มีการใช้งานเมื่อสัปดาห์ที่แล้ว",
"model.newThisWeek": "ใหม่ในสัปดาห์นี้",
"model.sameAsPreviousWeek": "เท่าเดิมจากสัปดาห์ก่อน",
"model.vsPreviousWeek": "{{change}} เทียบกับสัปดาห์ก่อน",
"model.pdf": "PDF",
"format.users": "ผู้ใช้",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Benzer model sıralamaları kullanım geldikten sonra görünür.",
"model.noUsageLastWeek": "Geçen hafta kullanım yok",
"model.newThisWeek": "Bu hafta yeni",
"model.sameAsPreviousWeek": "Önceki haftayla aynı",
"model.vsPreviousWeek": "önceki haftaya göre {{change}}",
"model.pdf": "PDF",
"format.users": "kullanıcı",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Рейтинги схожих моделей з'являться після використання.",
"model.noUsageLastWeek": "Немає використання минулого тижня",
"model.newThisWeek": "Нова цього тижня",
"model.sameAsPreviousWeek": "Без змін відносно попереднього тижня",
"model.vsPreviousWeek": "{{change}} до попереднього тижня",
"model.pdf": "PDF",
"format.users": "користувачі",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "使用量到达后会显示同类模型排名。",
"model.noUsageLastWeek": "上周无使用量",
"model.newThisWeek": "本周新增",
"model.sameAsPreviousWeek": "与上周相同",
"model.vsPreviousWeek": "较上周 {{change}}",
"model.pdf": "PDF",
"format.users": "用户",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "使用量到達後會顯示同類模型排名。",
"model.noUsageLastWeek": "上週無使用量",
"model.newThisWeek": "本週新增",
"model.sameAsPreviousWeek": "與上週相同",
"model.vsPreviousWeek": "較上週 {{change}}",
"model.pdf": "PDF",
"format.users": "使用者",
+42 -21
View File
@@ -30,6 +30,7 @@ import {
type ModelCatalogCost,
type ModelCatalogEntry,
} from "../model-catalog"
import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
@@ -205,7 +206,11 @@ function ModelLoading() {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{i18n.t("model.loadingTitle")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{i18n.t("model.loadingTitle")}
</a>
</h1>
<p>{i18n.t("model.loadingDescription")}</p>
</div>
</div>
@@ -228,7 +233,11 @@ function ModelNotFound(props: { lab: string; model: string }) {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{props.model || i18n.t("model.fallback")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{props.model || i18n.t("model.fallback")}
</a>
</h1>
<p>{i18n.t("model.noMatched", { id: props.lab ? `${props.lab}/${props.model}` : props.model })}</p>
</div>
</div>
@@ -260,7 +269,11 @@ function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEn
</a>
<span data-slot="model-id-tag">{modelId()}</span>
</div>
<h1>{props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")}
</a>
</h1>
<Show when={props.data} fallback={<p>{i18n.t("model.catalogFallback")}</p>}>
{(data) => (
<p>
@@ -352,8 +365,12 @@ function CatalogDatum(props: { label: string; value: string }) {
function ModelOverview(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<section data-section="model-panel">
<SectionTitle title={i18n.t("nav.overview")} description={i18n.t("model.overviewDescription")} />
<section id="model-overview" data-section="model-panel">
<SectionTitle
href="#model-overview"
title={i18n.t("nav.overview")}
description={i18n.t("model.overviewDescription")}
/>
<Show
when={props.data}
fallback={
@@ -399,7 +416,7 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
const i18n = useI18n()
return (
<section id="usage" data-section="model-panel">
<SectionTitle title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
<SectionTitle href="#usage" title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
<Show
when={props.data.some((item) => item.tokens > 0)}
fallback={
@@ -416,7 +433,7 @@ function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
const i18n = useI18n()
return (
<section id="users" data-section="model-panel">
<SectionTitle title={i18n.t("model.uniqueUsers")} description={i18n.t("model.usersDescription")} />
<SectionTitle href="#users" title={i18n.t("model.uniqueUsers")} description={i18n.t("model.usersDescription")} />
<Show
when={props.data.some((item) => item.users > 0)}
fallback={
@@ -550,7 +567,11 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M
const i18n = useI18n()
return (
<section id="efficiency" data-section="model-panel">
<SectionTitle title={i18n.t("nav.efficiency")} description={i18n.t("model.efficiencyDescription")} />
<SectionTitle
href="#efficiency"
title={i18n.t("nav.efficiency")}
description={i18n.t("model.efficiencyDescription")}
/>
<Show
when={props.data}
fallback={
@@ -623,7 +644,11 @@ function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry
setActiveCountry(undefined)
}}
>
<SectionTitle title={i18n.t("nav.geoBreakdown")} description={i18n.t("model.geoDescription")} />
<SectionTitle
href="#geo-breakdown"
title={i18n.t("nav.geoBreakdown")}
description={i18n.t("model.geoDescription")}
/>
<Show
when={data().length > 0}
fallback={<ModelEmptyState title={i18n.t("model.noGeoTitle")} description={i18n.t("model.noGeoDescription")} />}
@@ -788,7 +813,7 @@ function ModelPeersSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<section id="peers" data-section="model-panel">
<SectionTitle title={i18n.t("nav.peers")} description={i18n.t("model.peersDescription")} />
<SectionTitle href="#peers" title={i18n.t("nav.peers")} description={i18n.t("model.peersDescription")} />
<Show
when={props.data?.peers.length}
fallback={
@@ -833,12 +858,8 @@ function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) {
)
}
function SectionTitle(props: { title: string; description: string }) {
return (
<p data-slot="section-title">
<strong>{props.title}.</strong> <span>{props.description}</span>
</p>
)
function SectionTitle(props: { href: string; title: string; description: string }) {
return <SectionHeading href={props.href} title={props.title} description={props.description} />
}
function ModelEmptyState(props: { title: string; description: string; compact?: boolean }) {
@@ -912,17 +933,17 @@ function isModelUsageLabelHidden(index: number, count: number) {
return index !== count - 1 && index % interval !== 0
}
function formatRankMove(previousRank: number, rank: number) {
const change = previousRank - rank
function formatRankMove(change: number) {
if (change > 0) return `+${change}`
if (change < 0) return `${change}`
return "0"
return `${change}`
}
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
if (data.previousRank === null) return i18n.t("model.newThisWeek")
return i18n.t("model.vsPreviousWeek", { change: formatRankMove(data.previousRank, data.rank) })
const change = data.previousRank - data.rank
if (change === 0) return i18n.t("model.sameAsPreviousWeek")
return i18n.t("model.vsPreviousWeek", { change: formatRankMove(change) })
}
function formatTokens(value: number) {
+26 -11
View File
@@ -20,6 +20,7 @@ import {
type ModelCatalogEntry,
type ModelCatalogLab,
} from "../model-catalog"
import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
@@ -148,7 +149,11 @@ function LabLoading() {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{i18n.t("lab.loadingTitle")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{i18n.t("lab.loadingTitle")}
</a>
</h1>
<p>{i18n.t("lab.loadingDescription")}</p>
</div>
</div>
@@ -166,7 +171,11 @@ function LabNotFound(props: { lab: string }) {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{formatCatalogLabName(props.lab)}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{formatCatalogLabName(props.lab)}
</a>
</h1>
<p>{i18n.t("lab.notFound")}</p>
</div>
</div>
@@ -193,7 +202,11 @@ function LabHero(props: { lab: ModelCatalogLab; stats: StatsLabData | null }) {
</a>
<div data-slot="model-hero-grid">
<div data-slot="model-hero-copy">
<h1>{props.lab.name}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{props.lab.name}
</a>
</h1>
<div data-slot="model-hero-pattern" aria-hidden="true" />
<p>
{i18n.t("lab.heroPrefix", { count: props.lab.models.length, lab: props.lab.name })}
@@ -235,10 +248,11 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
return (
<section id="usage" data-section="model-panel">
<p data-slot="section-title">
<strong>{i18n.t("lab.usageTitle", { lab: props.lab.name })}.</strong>{" "}
<span>{i18n.t("lab.usageDescription")}</span>
</p>
<SectionHeading
href="#usage"
title={i18n.t("lab.usageTitle", { lab: props.lab.name })}
description={i18n.t("lab.usageDescription")}
/>
<Show
when={usage().some((item) => item.tokens > 0)}
fallback={<LabEmptyState title={i18n.t("lab.noUsageTitle")} description={i18n.t("lab.noUsageDescription")} />}
@@ -336,10 +350,11 @@ function LabModelsSection(props: { lab: ModelCatalogLab; usage: LabUsageModelEnt
const usageBySlug = createMemo(() => new Map(props.usage.map((item) => [item.slug, item])))
return (
<section id="models" data-section="model-panel">
<p data-slot="section-title">
<strong>{i18n.t("lab.modelsTitle", { lab: props.lab.name })}.</strong>{" "}
<span>{i18n.t("lab.recentUsageAndLimits")}</span>
</p>
<SectionHeading
href="#models"
title={i18n.t("lab.modelsTitle", { lab: props.lab.name })}
description={i18n.t("lab.recentUsageAndLimits")}
/>
<div data-component="lab-model-grid">
<For each={props.lab.models}>
{(model) => <LabModelCard model={model} usage={usageBySlug().get(model.slug)} />}
+34
View File
@@ -87,6 +87,11 @@
display: none !important;
}
[data-page="stats"] section[id],
[data-page="stats"] [data-component="leaderboard"][id] {
scroll-margin-top: 88px;
}
[data-page="stats"] [data-component="content"] {
color: var(--stats-text);
font-family:
@@ -1781,6 +1786,35 @@
font-weight: 400;
}
[data-page="stats"] [data-slot="heading-link"] {
position: relative;
color: inherit;
text-decoration: none;
}
[data-page="stats"] [data-slot="heading-link"]:hover {
text-decoration: none;
}
[data-page="stats"] [data-slot="heading-link"]:focus-visible {
outline: 1px solid var(--stats-accent);
outline-offset: 4px;
}
[data-page="stats"] [data-slot="heading-anchor"] {
position: absolute;
top: -0.08em;
right: 100%;
margin-right: 0.48em;
color: var(--stats-accent);
opacity: 0;
}
[data-page="stats"] [data-slot="heading-link"]:hover [data-slot="heading-anchor"],
[data-page="stats"] [data-slot="heading-link"]:focus-visible [data-slot="heading-anchor"] {
opacity: 1;
}
[data-page="stats"] [data-component="leaderboard"],
[data-page="stats"] [data-slot="leaderboard-featured"],
[data-page="stats"] [data-slot="leaderboard-compact"],
+51 -18
View File
@@ -32,6 +32,7 @@ import { useI18n } from "../context/i18n"
import { useLanguage } from "../context/language"
import { localizedUrl } from "../lib/language"
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
import { SectionHeading } from "./section-heading"
import { setStatsPageCacheHeaders } from "./stats-cache"
import {
applyThemePreference,
@@ -273,7 +274,11 @@ function Hero(props: { updatedAt: string | null }) {
</p>
<div data-slot="hero-canvas">
<div data-slot="hero-pattern" aria-hidden="true" />
<h1>{i18n.t("footer.modelData")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{i18n.t("footer.modelData")}
</a>
</h1>
<p data-slot="hero-copy">{i18n.t("home.heroCopy")}</p>
</div>
</section>
@@ -296,7 +301,7 @@ function StatsLoading() {
return (
<>
<Hero updatedAt={null} />
<ChartSection title={i18n.t("home.usageTitle")}>
<ChartSection id="top-models" title={i18n.t("home.usageTitle")}>
<EmptyState title={i18n.t("home.loadingTitle")} description={i18n.t("home.loadingDescription")} />
</ChartSection>
</>
@@ -314,7 +319,15 @@ function ChartSection(props: {
<section id={props.id} data-section="chart">
<div data-slot="section-header">
<div>
<h2>{props.title}</h2>
<h2>
<Show when={props.id} fallback={props.title}>
{(id) => (
<a data-slot="heading-link" href={`#${id()}`}>
{props.title}
</a>
)}
</Show>
</h2>
{props.description && <p>{props.description}</p>}
</div>
{props.controls}
@@ -324,12 +337,8 @@ function ChartSection(props: {
)
}
function SectionTitle(props: { title: string; description: string }) {
return (
<p data-slot="section-title">
<strong>{props.title}.</strong> <span>{props.description}</span>
</p>
)
function SectionTitle(props: { id: string; title: string; description: string }) {
return <SectionHeading href={`#${props.id}`} title={props.title} description={props.description} />
}
function SectionBridge(props: { label: string; href: string }) {
@@ -405,9 +414,13 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
return (
<section id="top-models" data-section="top-models">
<h2 data-slot="top-models-title">
<strong>{i18n.t("nav.topModels")}.</strong> <span>{i18n.t("home.topModelsDescription")}</span>
</h2>
<SectionHeading
as="h2"
slot="top-models-title"
href="#top-models"
title={i18n.t("nav.topModels")}
description={i18n.t("home.topModelsDescription")}
/>
<Show
when={data().some((item) => usageTotal(item) > 0)}
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
@@ -802,7 +815,11 @@ function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
return (
<section id="unique-users" data-section="unique-users">
<SectionBridge label={i18n.t("nav.topModels").toUpperCase()} href="#top-models" />
<SectionTitle title={i18n.t("home.uniqueUsersTitle")} description={i18n.t("home.uniqueUsersDescription")} />
<SectionTitle
id="unique-users"
title={i18n.t("home.uniqueUsersTitle")}
description={i18n.t("home.uniqueUsersDescription")}
/>
<Show
when={data().some((item) => usageTotal(item) > 0)}
fallback={
@@ -1073,7 +1090,11 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
}}
>
<SectionBridge label={i18n.t("nav.cacheRatio").toUpperCase()} href="#cache-ratio" />
<SectionTitle title={i18n.t("home.marketShareTitle")} description={i18n.t("home.marketShareDescription")} />
<SectionTitle
id="market-share"
title={i18n.t("home.marketShareTitle")}
description={i18n.t("home.marketShareDescription")}
/>
<Show
when={activeDay()}
fallback={<EmptyState title={i18n.t("home.noMarketTitle")} description={i18n.t("home.noMarketDescription")} />}
@@ -1298,7 +1319,7 @@ function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
}}
>
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
<SectionTitle title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
<SectionTitle id="geo-breakdown" title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
<Show
when={data().length > 0}
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
@@ -1583,7 +1604,11 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: Mo
return (
<section id="token-cost" data-section="token-cost">
<SectionBridge label={i18n.t("nav.sessionCost").toUpperCase()} href="#session-cost" />
<SectionTitle title={i18n.t("home.tokenCostTitle")} description={i18n.t("home.tokenCostDescription")} />
<SectionTitle
id="token-cost"
title={i18n.t("home.tokenCostTitle")}
description={i18n.t("home.tokenCostDescription")}
/>
<Show
when={visible().length > 0}
fallback={
@@ -1666,7 +1691,11 @@ function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
return (
<section id="cache-ratio" data-section="cache-ratio">
<SectionBridge label={i18n.t("nav.tokenCost").toUpperCase()} href="#token-cost" />
<SectionTitle title={i18n.t("home.cacheRatioTitle")} description={i18n.t("home.cacheRatioDescription")} />
<SectionTitle
id="cache-ratio"
title={i18n.t("home.cacheRatioTitle")}
description={i18n.t("home.cacheRatioDescription")}
/>
<Show
when={visible().length > 0}
fallback={<EmptyState title={i18n.t("home.noCacheTitle")} description={i18n.t("home.noCacheDescription")} />}
@@ -1792,7 +1821,11 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
return (
<section id="session-cost" data-section="session-cost">
<SectionBridge label={i18n.t("nav.topModels").toUpperCase()} href="#top-models" />
<SectionTitle title={i18n.t("home.sessionCostTitle")} description={i18n.t("home.sessionCostDescription")} />
<SectionTitle
id="session-cost"
title={i18n.t("home.sessionCostTitle")}
description={i18n.t("home.sessionCostDescription")}
/>
<Show
when={visible().length > 0}
fallback={
@@ -0,0 +1,24 @@
export function SectionHeading(props: {
href: string
title: string
description: string
as?: "h2" | "p"
slot?: string
}) {
const content = (
<>
<strong>
<a data-slot="heading-link" href={props.href}>
<span data-slot="heading-anchor" aria-hidden="true">
#
</span>
{props.title}.
</a>
</strong>{" "}
<span>{props.description}</span>
</>
)
if (props.as === "h2") return <h2 data-slot={props.slot ?? "section-title"}>{content}</h2>
return <p data-slot={props.slot ?? "section-title"}>{content}</p>
}
+1
View File
@@ -54,6 +54,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
+60 -47
View File
@@ -21,11 +21,10 @@ import {
onCleanup,
batch,
Show,
on,
} from "solid-js"
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
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 { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project"
@@ -33,8 +32,9 @@ import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
@@ -76,7 +76,7 @@ import {
useOpencodeKeymap,
} from "./keymap"
import type { EventSource } from "./context/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio"
@@ -134,14 +134,11 @@ const appBindingCommands = [
] as const
export type TuiInput = {
url: string
client: OpencodeClient
reload?: () => Promise<OpencodeClient>
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
}
@@ -268,7 +265,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiStartupProvider
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),
}}
>
@@ -289,13 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SDKProvider client={input.client} reload={input.reload}>
<ProjectProvider>
<SyncProvider>
<DataProvider>
@@ -366,10 +361,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const reload = sdk.reload
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const data = useData()
const project = useProject()
const exit = useExit()
const promptRef = usePromptRef()
@@ -389,6 +386,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event,
sdk,
sync,
data,
theme: themeState,
toast,
renderer,
@@ -494,9 +492,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
createEffect(() => {
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
if (continued || sync.status === "loading" || !args.continue) return
const match = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.find((x) => x.parentID === undefined)?.id
const match = data.session.list().find((session) => !session.parentID)?.id
if (match) {
continued = true
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 currentWorktreeWorkspace = createMemo(() => {
const workspaceID = project.workspace.current()
@@ -563,7 +548,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
name: "session.list",
title: "Switch session",
category: "Session",
suggested: sync.data.session.length > 0,
suggested: data.session.list().length > 0,
slashName: "sessions",
slashAliases: ["resume", "continue"],
run: () => {
@@ -729,13 +714,13 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
{
name: "provider.connect",
title: "Connect provider",
title: "Connect integration",
suggested: !connected(),
slashName: "connect",
run: () => {
dialog.replace(() => <DialogProviderList />)
dialog.replace(() => <DialogIntegration />)
},
category: "Provider",
category: "Integration",
},
...(sync.data.console_state.switchableOrgCount > 1
? [
@@ -761,6 +746,33 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
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",
title: "Switch theme",
@@ -957,16 +969,16 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
keymap.dispatchCommand(evt.properties.command)
keymap.dispatchCommand(evt.data.command)
})
event.on("tui.toast.show", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
toast.show({
title: evt.properties.title,
message: evt.properties.message,
variant: evt.properties.variant,
duration: evt.properties.duration,
title: evt.data.title,
message: evt.data.message,
variant: evt.data.variant,
duration: evt.data.duration,
})
})
@@ -974,12 +986,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
if (workspace !== project.workspace.current()) return
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
sessionID: evt.data.sessionID,
})
})
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" })
toast.show({
variant: "info",
@@ -990,7 +1002,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event.on("session.error", (evt, { workspace }) => {
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
const message = errorMessage(error)
@@ -1003,7 +1015,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.properties.version
const version = evt.data.version
const skipped = kv.get("skipped_version")
if (skipped && !isVersionGreater(version, skipped)) return
@@ -1087,9 +1099,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session />}
</Show>
<Session />
</Match>
</Switch>
{plugin()}
@@ -1102,6 +1112,9 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
<Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} />
</Show>
<Show when={sdk.connection.status() === "reconnecting"}>
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
</Show>
</box>
)
}
+4 -4
View File
@@ -10,9 +10,9 @@ export function DialogAgent() {
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
value: item.id,
title: item.id,
description: item.description,
}
}),
)
@@ -20,7 +20,7 @@ export function DialogAgent() {
return (
<DialogSelect
title="Select agent"
current={local.agent.current()?.name}
current={local.agent.current()?.id}
options={options()}
onSelect={(option) => {
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"
}

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