mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ac3128c74 | |||
| 5e90a68d6a | |||
| 727f9f46a0 | |||
| 11bf8d8a42 | |||
| c22973ab9f | |||
| 612009e0f1 | |||
| a0a6c9545e | |||
| bceb6d0a9e | |||
| 49e7dc191f | |||
| f4720be08e | |||
| d1d7ebc2c6 | |||
| cd942d0669 | |||
| 4741e3ee6b | |||
| 4673bfbaa0 | |||
| b458dd8c63 | |||
| 7edd6c3a1d | |||
| f43f066741 | |||
| b6553d14e1 | |||
| ab0042a666 | |||
| 5df049d081 | |||
| 573ab9c24b | |||
| 6a16c41e8f | |||
| a491cbee64 | |||
| 658cbe9caf | |||
| 62b2bc39df | |||
| beb2c52c3f | |||
| 655adbf46e | |||
| ac2a78391f | |||
| 9a9bdaba95 | |||
| 8b682c42b6 | |||
| ad4f1c1018 | |||
| a0afb63ed0 | |||
| df9ecb8f6a | |||
| e6f660fecf |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -106,12 +106,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -936,6 +939,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:",
|
||||
@@ -5979,6 +5983,8 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
+2
-1
@@ -2,11 +2,12 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Server/API debugging
|
||||
|
||||
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
|
||||
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
|
||||
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
|
||||
|
||||
```bash
|
||||
bun dev api get /health
|
||||
bun dev api get /openapi.json
|
||||
bun dev api <operationId> --param key=value
|
||||
```
|
||||
|
||||
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## 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",
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs"
|
||||
"opencode2": "./bin/opencode2.cjs"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
@@ -25,12 +25,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
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"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
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 updater = yield* Updater.Service
|
||||
yield* updater.check()
|
||||
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())
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,9 +6,12 @@ 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"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.serve,
|
||||
@@ -16,15 +19,43 @@ 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}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
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 +66,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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Spec } from "./spec"
|
||||
import { Daemon } from "../services/daemon"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Scope } from "effect"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -10,11 +12,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 | Updater.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 | Updater.Service | Scope.Scope>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
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"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
|
||||
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"),
|
||||
@@ -24,9 +35,14 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Daemon.defaultLayer),
|
||||
Effect.provide(Updater.defaultLayer),
|
||||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { action, decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("skips when autoupdate is disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
||||
})
|
||||
|
||||
test("never automatically updates majors", () => {
|
||||
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import {
|
||||
InstallationChannel,
|
||||
InstallationLocal,
|
||||
InstallationVersion,
|
||||
} from "@opencode-ai/core/installation/version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import semver from "semver"
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName = "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
// The CLI only projects this host-level preference instead of initializing
|
||||
// the location-scoped server configuration graph.
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
|
||||
const value = input.autoupdate
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs
|
||||
.readFileString(path.join(global.config, name))
|
||||
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? true
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
maxOutputBytes: 100_000,
|
||||
maxErrorBytes: 100_000,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
code: result.exitCode,
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
})
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Method, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "install", "--global", target],
|
||||
bun: ["bun", "install", "--global", target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* run(commands[method], "5 minutes")
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
||||
return yield* Effect.logInfo("update check skipped", {
|
||||
reason: InstallationLocal ? "local-install" : "disabled",
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
})
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: InstallationVersion,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(InstallationVersion, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
||||
})
|
||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||
|
||||
return Service.of({ check })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
|
||||
export * as Updater from "./updater"
|
||||
+38
-28
@@ -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
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -106,15 +106,25 @@ const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Inp
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
|
||||
type Endpoint3_6Input = {
|
||||
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_6Request["payload"]["resume"]
|
||||
readonly title: Endpoint3_6Request["payload"]["title"]
|
||||
}
|
||||
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint3_7Input = {
|
||||
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_7Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_7Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_7Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_7Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
@@ -123,23 +133,23 @@ const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
|
||||
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
|
||||
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_9Input = {
|
||||
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_9Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_9Request["payload"]["files"]
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_10Input = {
|
||||
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_10Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -148,42 +158,42 @@ const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_13Input = {
|
||||
readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_13Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_13Request["query"]["after"]
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_14Input = {
|
||||
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_14Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_14Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_14Input = {
|
||||
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_14Request["query"]["after"]
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_15Input = {
|
||||
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_15Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -191,17 +201,17 @@ const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_16Input = {
|
||||
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_16Request["params"]["messageID"]
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_17Input = {
|
||||
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_17Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -214,17 +224,18 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
||||
get: Endpoint3_3(raw),
|
||||
switchAgent: Endpoint3_4(raw),
|
||||
switchModel: Endpoint3_5(raw),
|
||||
prompt: Endpoint3_6(raw),
|
||||
compact: Endpoint3_7(raw),
|
||||
wait: Endpoint3_8(raw),
|
||||
stage: Endpoint3_9(raw),
|
||||
clear: Endpoint3_10(raw),
|
||||
commit: Endpoint3_11(raw),
|
||||
context: Endpoint3_12(raw),
|
||||
history: Endpoint3_13(raw),
|
||||
events: Endpoint3_14(raw),
|
||||
interrupt: Endpoint3_15(raw),
|
||||
message: Endpoint3_16(raw),
|
||||
rename: Endpoint3_6(raw),
|
||||
prompt: Endpoint3_7(raw),
|
||||
compact: Endpoint3_8(raw),
|
||||
wait: Endpoint3_9(raw),
|
||||
stage: Endpoint3_10(raw),
|
||||
clear: Endpoint3_11(raw),
|
||||
commit: Endpoint3_12(raw),
|
||||
context: Endpoint3_13(raw),
|
||||
history: Endpoint3_14(raw),
|
||||
events: Endpoint3_15(raw),
|
||||
interrupt: Endpoint3_16(raw),
|
||||
message: Endpoint3_17(raw),
|
||||
})
|
||||
|
||||
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
SessionsSwitchAgentOutput,
|
||||
SessionsSwitchModelInput,
|
||||
SessionsSwitchModelOutput,
|
||||
SessionsRenameInput,
|
||||
SessionsRenameOutput,
|
||||
SessionsPromptInput,
|
||||
SessionsPromptOutput,
|
||||
SessionsCompactInput,
|
||||
@@ -367,6 +369,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsRenameOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
|
||||
body: { title: input["title"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsPromptOutput }>(
|
||||
{
|
||||
@@ -408,7 +422,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
||||
body: { messageID: input["messageID"], files: input["files"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 409, 500, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -419,7 +433,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
declaredStatuses: [404, 409, 500, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -430,7 +444,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 409, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -58,6 +58,14 @@ export type MessageNotFoundError = {
|
||||
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
|
||||
|
||||
export type UnknownError = {
|
||||
readonly _tag: "UnknownError"
|
||||
readonly message: string
|
||||
@@ -381,6 +389,13 @@ export type SessionsSwitchModelInput = {
|
||||
|
||||
export type SessionsSwitchModelOutput = void
|
||||
|
||||
export type SessionsRenameInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly title: { readonly title: string }["title"]
|
||||
}
|
||||
|
||||
export type SessionsRenameOutput = void
|
||||
|
||||
export type SessionsPromptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
@@ -723,6 +738,14 @@ export type SessionsHistoryOutput = {
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.renamed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
@@ -1181,6 +1204,14 @@ export type SessionsEventsOutput =
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.renamed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export * as PublicEventManifest from "./public-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)
|
||||
@@ -78,9 +78,12 @@ export type ListInput = typeof ListInput.Type
|
||||
|
||||
type CreateInput = {
|
||||
id?: SessionSchema.ID
|
||||
parentID?: SessionSchema.ID
|
||||
title?: string
|
||||
agent?: AgentV2.ID
|
||||
model?: ModelV2.Ref
|
||||
location: Location.Ref
|
||||
// Optional when parentID is given: the child inherits the parent Session's location.
|
||||
location?: Location.Ref
|
||||
}
|
||||
|
||||
type CompactInput = {
|
||||
@@ -105,6 +108,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
@@ -144,6 +150,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -164,7 +171,7 @@ export interface Interface {
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
@@ -173,9 +180,9 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +216,12 @@ export const layer = Layer.effect(
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
// An explicit location wins; otherwise a child inherits its parent's location.
|
||||
const parent = input.location === undefined && input.parentID ? yield* store.get(input.parentID) : undefined
|
||||
const location = input.location ?? parent?.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
@@ -222,10 +234,11 @@ export const layer = Layer.effect(
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
directory: input.location.directory,
|
||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||
title: `New session - ${new Date(now).toISOString()}`,
|
||||
parentID: input.parentID,
|
||||
directory: location.directory,
|
||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
|
||||
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
@@ -239,7 +252,7 @@ export const layer = Layer.effect(
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location })
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
@@ -414,13 +427,21 @@ export const layer = Layer.effect(
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("V2Session.rename")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
yield* events.publish(SessionEvent.Renamed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
title: input.title,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
}),
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
@@ -433,6 +454,8 @@ export const layer = Layer.effect(
|
||||
revert: {
|
||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if ((yield* execution.active).has(input.sessionID))
|
||||
return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
@@ -441,6 +464,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
@@ -448,6 +472,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface Interface {
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
@@ -30,5 +32,6 @@ export const noopLayer = Layer.succeed(
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ export const layer = Layer.effect(
|
||||
interrupt: coordinator.interrupt,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.moved": () => Effect.void,
|
||||
"session.next.renamed": () => Effect.void,
|
||||
"session.next.prompted": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.User.make({
|
||||
|
||||
@@ -347,6 +347,14 @@ export const layer = Layer.effectDiscard(
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Renamed, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Coordinator<Key, E> {
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Entry<E> = {
|
||||
@@ -100,5 +102,15 @@ export const make = <Key, E>(options: {
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
||||
// Each successful drain reuses its entry.done across coalesced wakes, so one await
|
||||
// already spans steered and queued continuation. Re-check after it settles to cover a
|
||||
// fresh wake (or a failure/stopping successor) that installs a new entry.
|
||||
const awaitIdle = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry === undefined) return Effect.void
|
||||
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -289,19 +289,18 @@ export const layer = Layer.effect(
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (
|
||||
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
|
||||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
) {
|
||||
if (streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (publisher.hasActiveAssistant())
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -309,7 +308,7 @@ export const layer = Layer.effect(
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !publisher.hasProviderError()) {
|
||||
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
|
||||
@@ -43,6 +43,7 @@ const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
@@ -64,6 +65,9 @@ const registryLayer = Layer.effect(
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
agents: {
|
||||
resolve: agents.resolve,
|
||||
},
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
@@ -136,17 +140,18 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(AgentV2.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
deps: [ApplicationTools.node, AgentV2.node, ToolOutputStore.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
deps: [ApplicationTools.node, AgentV2.node, ToolOutputStore.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
export * as SubagentTool from "./subagent"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { DateTime, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { BackgroundJob } from "../background-job"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const BACKGROUND_STARTED =
|
||||
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
model: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Optional model override in 'providerID/modelID' form; defaults to the agent or session model",
|
||||
}),
|
||||
background: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
status: Schema.Literals(["completed", "running"]),
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
].join("\n")
|
||||
|
||||
// Accept "providerID/modelID" overrides; anything malformed falls back to the agent/session default.
|
||||
const parseModel = (value: string | undefined): ModelV2.Ref | undefined => {
|
||||
if (value === undefined || !value.includes("/")) return undefined
|
||||
const parsed = ModelV2.parse(value)
|
||||
return ModelV2.Ref.make({ providerID: parsed.providerID, id: parsed.modelID })
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
||||
const text = assistant.content
|
||||
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
description: string,
|
||||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
) {
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: parentID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
})
|
||||
})
|
||||
|
||||
const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
description: string,
|
||||
) {
|
||||
yield* jobs.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
|
||||
if (result.info?.status === "error")
|
||||
return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed")
|
||||
if (result.info?.status === "cancelled")
|
||||
return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled")
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* context.agents.resolve(input.agent)
|
||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||
if (agent.mode === "primary")
|
||||
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
|
||||
|
||||
// Precedence: explicit input model -> agent's configured model -> parent session model.
|
||||
const parent = yield* sessions.get(context.sessionID).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
)
|
||||
const model = parseModel(input.model) ?? agent.model ?? parent.model
|
||||
|
||||
const child = yield* sessions.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: AgentV2.ID.make(input.agent),
|
||||
model,
|
||||
// No location: the child inherits the parent's location.
|
||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
})
|
||||
|
||||
const background = input.background === true
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt } })
|
||||
yield* sessions.wait(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
})
|
||||
|
||||
const info = yield* jobs.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
onPromote: injectWhenDone(context.sessionID, child.id, input.description),
|
||||
run,
|
||||
})
|
||||
|
||||
if (background) {
|
||||
if ((yield* jobs.promote(info.id)) === undefined)
|
||||
yield* injectWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
|
||||
const result = yield* Effect.raceFirst(
|
||||
jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)),
|
||||
jobs.waitForPromotion(child.id),
|
||||
).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
|
||||
),
|
||||
)
|
||||
if (result?.metadata?.background === true)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
if (result?.status === "error")
|
||||
return yield* new ToolFailure({ message: result.error ?? "Subagent failed" })
|
||||
if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above
|
||||
// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle.
|
||||
// The location ToolRegistry supplies call-local agent lookup through Tool.Context.
|
||||
export const node = makeGlobalNode({
|
||||
name: "subagent-tool",
|
||||
layer,
|
||||
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node],
|
||||
})
|
||||
@@ -11,6 +11,9 @@ export interface Context {
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
readonly agents: {
|
||||
readonly resolve: (id?: AgentV2.ID | string) => Effect.Effect<AgentV2.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any, never, never>
|
||||
|
||||
@@ -19,6 +19,7 @@ const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(AgentV2.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(applications, registry))
|
||||
@@ -66,7 +67,7 @@ describe("ApplicationTools", () => {
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
|
||||
expect(contexts[0]).toMatchObject({ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -105,7 +106,7 @@ describe("ApplicationTools", () => {
|
||||
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
|
||||
expect(contexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -143,7 +144,7 @@ describe("ApplicationTools", () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
|
||||
expect(contexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -256,8 +257,8 @@ describe("ApplicationTools", () => {
|
||||
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
|
||||
})
|
||||
|
||||
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
|
||||
expect(secondContexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
|
||||
expect(firstContexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -284,7 +285,7 @@ describe("ApplicationTools", () => {
|
||||
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
|
||||
expect(locationContexts).toMatchObject([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
|
||||
expect(applicationContexts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -102,6 +102,16 @@ describe("SessionV2.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits location from an existing parent when omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const parent = yield* session.create({ location })
|
||||
const child = yield* session.create({ parentID: parent.id, title: "child" })
|
||||
|
||||
expect(child).toMatchObject({ parentID: parent.id, location })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
@@ -39,6 +39,7 @@ const execution = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
|
||||
@@ -99,6 +99,7 @@ const execution = Layer.effect(
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runner))
|
||||
|
||||
@@ -27,7 +27,11 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
)
|
||||
},
|
||||
})
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(AgentV2.layer),
|
||||
Layer.provide(outputStore),
|
||||
)
|
||||
const it = testEffect(registry)
|
||||
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
|
||||
const identity = {
|
||||
@@ -237,7 +241,7 @@ describe("ToolRegistry", () => {
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
|
||||
expect(contexts).toMatchObject([{ sessionID, ...identity, toolCallID: "call-context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(AgentV2.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const agents = AgentV2.layer
|
||||
@@ -258,6 +259,7 @@ const execution = Layer.effect(
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runner))
|
||||
@@ -371,6 +373,21 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
|
||||
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
|
||||
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
|
||||
|
||||
const recordedEventTypes = (id: SessionV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map((row) => row.type)),
|
||||
)
|
||||
})
|
||||
|
||||
const replaySessionProjection = (id: SessionV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
@@ -591,7 +608,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
|
||||
expect(contexts).toEqual([
|
||||
expect(contexts).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
@@ -2772,6 +2789,11 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Interrupt provider" },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2")
|
||||
yield* session.interrupt(sessionID)
|
||||
}),
|
||||
)
|
||||
@@ -2801,6 +2823,8 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "user", text: "Interrupt tool settlement" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
@@ -2810,6 +2834,9 @@ describe("SessionRunnerLLM", () => {
|
||||
],
|
||||
},
|
||||
])
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes).toContain("session.next.step.failed.2")
|
||||
expect(eventTypes).not.toContain("session.next.step.ended.2")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const awaited: SessionV2.ID[] = []
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const execution = Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(execution),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
projects,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
|
||||
describe("SessionV2.wait", () => {
|
||||
it.effect("delegates to SessionExecution.awaitIdle", () =>
|
||||
Effect.gen(function* () {
|
||||
awaited.length = 0
|
||||
const sessions = yield* SessionV2.Service
|
||||
const session = yield* sessions.create({ location })
|
||||
|
||||
yield* sessions.wait(session.id)
|
||||
|
||||
expect(awaited).toEqual([session.id])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { BackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, settleTool, toolIdentity } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
|
||||
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
const outputSessionID = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || !("sessionID" in value) || typeof value.sessionID !== "string") {
|
||||
throw new Error("Subagent output did not include a sessionID")
|
||||
}
|
||||
return SessionV2.ID.make(value.sessionID)
|
||||
}
|
||||
|
||||
const executionNode = makeGlobalNode({
|
||||
service: SessionExecution.Service,
|
||||
layer: Layer.effect(
|
||||
SessionExecution.Service,
|
||||
EventV2.Service.use((events) => {
|
||||
const completed = new Set<SessionV2.ID>()
|
||||
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: SessionV2.ID) {
|
||||
if (completed.has(sessionID)) return
|
||||
completed.add(sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_subagent_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
model: childModel,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
textID,
|
||||
text: childText,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens,
|
||||
})
|
||||
})
|
||||
return Effect.succeed(
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: complete,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
deps: [EventV2.node],
|
||||
})
|
||||
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.bind(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
BackgroundJob.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
SubagentTool.node,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
SessionExecution.node,
|
||||
executionNode,
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* AgentV2.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||
agent.mode = "subagent"
|
||||
agent.model = childModel
|
||||
})
|
||||
draft.update(AgentV2.ID.make("fallback"), (agent) => {
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
draft.update(AgentV2.ID.make("primary"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
|
||||
describe("SubagentTool", () => {
|
||||
it.live("registers globally while resolving agents from the caller location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
yield* withSubagent(location)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(location)))
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const parent = yield* session.create({ location })
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-primary",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "primary", description: "primary", prompt: "should fail" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("runs a foreground child session and returns the final assistant text", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
yield* withSubagent(location)
|
||||
const sessions = yield* SessionV2.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(location)))
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||
expect(child).toMatchObject({ parentID: parent.id, location, agent: "reviewer", model: childModel })
|
||||
|
||||
const fallback = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-fallback",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "fallback", description: "fallback", prompt: "fallback" },
|
||||
},
|
||||
})
|
||||
const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured))
|
||||
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("promotes background work and injects one synthetic parent completion", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
yield* withSubagent(location)
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const parent = yield* sessions.create({ location })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(location)))
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-background-subagent",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
|
||||
},
|
||||
})
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||
|
||||
yield* jobs.promote(childID)
|
||||
yield* Effect.yieldNow
|
||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(synthetic).toHaveLength(1)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(childText)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
+3
-129
@@ -1,131 +1,5 @@
|
||||
# opencode database guide
|
||||
# opencode package guidance
|
||||
|
||||
## Database
|
||||
`packages/opencode` is the V1 version of this project.
|
||||
|
||||
- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`.
|
||||
- **Migrations**: database migrations live in `packages/core` and are applied by core.
|
||||
|
||||
## Development server
|
||||
|
||||
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
|
||||
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
|
||||
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
|
||||
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
|
||||
|
||||
# Module shape
|
||||
|
||||
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||
standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
|
||||
runner. Use flat top-level exports combined with a self-reexport at the bottom
|
||||
of the file:
|
||||
|
||||
```ts
|
||||
// src/foo/foo.ts
|
||||
export interface Interface { ... }
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
export const layer = Layer.effect(Service, ...)
|
||||
export const defaultLayer = layer.pipe(...)
|
||||
|
||||
export * as Foo from "./foo"
|
||||
```
|
||||
|
||||
Consumers import the namespace projection:
|
||||
|
||||
```ts
|
||||
import { Foo } from "@/foo/foo"
|
||||
|
||||
yield * Foo.Service
|
||||
Foo.layer
|
||||
Foo.defaultLayer
|
||||
```
|
||||
|
||||
Namespace-private helpers stay as non-exported top-level declarations in the
|
||||
same file — they remain inaccessible to consumers (they are not projected by
|
||||
`export * as`) but are usable by the file's own code.
|
||||
|
||||
## When the file is an `index.ts`
|
||||
|
||||
If the module is `foo/index.ts` (single-namespace directory), use `"."` for
|
||||
the self-reexport source rather than `"./index"`:
|
||||
|
||||
```ts
|
||||
// src/foo/index.ts
|
||||
export const thing = ...
|
||||
|
||||
export * as Foo from "."
|
||||
```
|
||||
|
||||
## Multi-sibling directories
|
||||
|
||||
For directories with several independent modules (e.g. `src/session/`,
|
||||
`src/config/`), keep each sibling as its own file with its own self-reexport,
|
||||
and do not add a barrel `index.ts`. Consumers import the specific sibling:
|
||||
|
||||
```ts
|
||||
import { SessionRetry } from "@/session/retry"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
```
|
||||
|
||||
Barrels in multi-sibling directories force every import through the barrel to
|
||||
evaluate every sibling, which defeats tree-shaking and slows module load.
|
||||
|
||||
# opencode Effect rules
|
||||
|
||||
Use these rules when writing or migrating Effect code.
|
||||
|
||||
See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
|
||||
## Core
|
||||
|
||||
- Use `Effect.gen(function* () { ... })` for composition.
|
||||
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
|
||||
- Use `Effect.callback` for callback-based APIs.
|
||||
- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`.
|
||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||
|
||||
## Module conventions
|
||||
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||
|
||||
## Schemas and errors
|
||||
|
||||
- Use `Schema.Class` for multi-field data.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Runtime vs InstanceState
|
||||
|
||||
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
|
||||
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
|
||||
- If two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||
- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
|
||||
- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
|
||||
|
||||
## Effect v4 beta API
|
||||
|
||||
- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
||||
- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
|
||||
- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
|
||||
- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
|
||||
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
|
||||
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||
|
||||
## Callback boundaries
|
||||
|
||||
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
|
||||
|
||||
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
|
||||
We are moving to V2, which is split across the `core`, `tui`, and `cli` packages. It is okay to read code in this package to understand how V1 worked, but do not make changes here unless explicitly instructed.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -268,6 +268,9 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
["post", "/session/{sessionID}/revert"],
|
||||
["post", "/session/{sessionID}/unrevert"],
|
||||
["delete", "/session/{sessionID}/message/{messageID}"],
|
||||
["post", "/api/session/{sessionID}/revert/stage"],
|
||||
["post", "/api/session/{sessionID}/revert/clear"],
|
||||
["post", "/api/session/{sessionID}/revert/commit"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
|
||||
"SessionBusyError",
|
||||
|
||||
@@ -41,6 +41,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
@@ -56,7 +57,7 @@ import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
|
||||
const summary = Layer.succeed(
|
||||
SessionSummary.Service,
|
||||
@@ -191,6 +192,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(AgentV2.layer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,6 +25,15 @@ export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()(
|
||||
"SessionBusyError",
|
||||
{
|
||||
sessionID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
|
||||
"ServiceUnavailableError",
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
InvalidRequestError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "../errors"
|
||||
@@ -201,6 +202,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.rename", "/api/session/:sessionID/rename", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ title: Schema.String }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.rename",
|
||||
summary: "Rename session",
|
||||
description: "Update the session title.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: Session.ID },
|
||||
@@ -257,7 +274,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
|
||||
success: Schema.Struct({ data: Revert.State }),
|
||||
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
@@ -272,7 +289,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
error: [SessionNotFoundError, SessionBusyError, UnknownError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
|
||||
@@ -281,7 +298,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
error: [SessionNotFoundError, SessionBusyError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
|
||||
@@ -84,6 +84,16 @@ export const Moved = Event.define({
|
||||
})
|
||||
export type Moved = typeof Moved.Type
|
||||
|
||||
export const Renamed = Event.define({
|
||||
type: "session.next.renamed",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
title: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const Prompted = Event.define({
|
||||
type: "session.next.prompted",
|
||||
...options,
|
||||
@@ -449,6 +459,7 @@ export const DurableDefinitions = Event.inventory(
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
Moved,
|
||||
Renamed,
|
||||
Prompted,
|
||||
PromptAdmitted,
|
||||
ContextUpdated,
|
||||
@@ -480,6 +491,7 @@ export const Definitions = Event.inventory(
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
Moved,
|
||||
Renamed,
|
||||
Prompted,
|
||||
PromptAdmitted,
|
||||
ContextUpdated,
|
||||
|
||||
@@ -371,6 +371,8 @@ import type {
|
||||
V2SessionQuestionRejectResponses,
|
||||
V2SessionQuestionReplyErrors,
|
||||
V2SessionQuestionReplyResponses,
|
||||
V2SessionRenameErrors,
|
||||
V2SessionRenameResponses,
|
||||
V2SessionRevertClearErrors,
|
||||
V2SessionRevertClearResponses,
|
||||
V2SessionRevertCommitErrors,
|
||||
@@ -5614,6 +5616,41 @@ export class Session3 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename session
|
||||
*
|
||||
* Update the session title.
|
||||
*/
|
||||
public rename<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
title?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "body", key: "title" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SessionRenameResponses, V2SessionRenameErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/rename",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ export type Event =
|
||||
| EventSessionNextAgentSwitched
|
||||
| EventSessionNextModelSwitched
|
||||
| EventSessionNextMoved
|
||||
| EventSessionNextRenamed
|
||||
| EventSessionNextPrompted
|
||||
| EventSessionNextPromptAdmitted
|
||||
| EventSessionNextContextUpdated
|
||||
@@ -848,6 +849,15 @@ export type GlobalEvent = {
|
||||
subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.renamed"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.prompted"
|
||||
@@ -1611,6 +1621,7 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionNextAgentSwitched
|
||||
| SyncEventSessionNextModelSwitched
|
||||
| SyncEventSessionNextMoved
|
||||
| SyncEventSessionNextRenamed
|
||||
| SyncEventSessionNextPrompted
|
||||
| SyncEventSessionNextPromptAdmitted
|
||||
| SyncEventSessionNextContextUpdated
|
||||
@@ -2735,6 +2746,7 @@ export type SessionDurableEvent =
|
||||
| SessionNextAgentSwitched
|
||||
| SessionNextModelSwitched
|
||||
| SessionNextMoved
|
||||
| SessionNextRenamed
|
||||
| SessionNextPrompted
|
||||
| SessionNextPromptAdmitted
|
||||
| SessionNextContextUpdated
|
||||
@@ -2862,6 +2874,7 @@ export type V2Event =
|
||||
| SessionNextAgentSwitched
|
||||
| SessionNextModelSwitched
|
||||
| SessionNextMoved
|
||||
| SessionNextRenamed
|
||||
| SessionNextPrompted
|
||||
| SessionNextPromptAdmitted
|
||||
| SessionNextContextUpdated
|
||||
@@ -3344,6 +3357,22 @@ export type SyncEventSessionNextMoved = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextRenamed = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.next.renamed.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextPrompted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
@@ -4217,6 +4246,25 @@ export type SessionNextMoved = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionNextRenamed = {
|
||||
id: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.next.renamed"
|
||||
durable?: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionNextPrompted = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -6276,6 +6324,16 @@ export type EventSessionNextMoved = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextRenamed = {
|
||||
id: string
|
||||
type: "session.next.renamed"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextPrompted = {
|
||||
id: string
|
||||
type: "session.next.prompted"
|
||||
@@ -11545,6 +11603,43 @@ export type V2SessionSwitchModelResponses = {
|
||||
|
||||
export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses]
|
||||
|
||||
export type V2SessionRenameData = {
|
||||
body: {
|
||||
title: string
|
||||
}
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/rename"
|
||||
}
|
||||
|
||||
export type V2SessionRenameErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
}
|
||||
|
||||
export type V2SessionRenameError = V2SessionRenameErrors[keyof V2SessionRenameErrors]
|
||||
|
||||
export type V2SessionRenameResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses]
|
||||
|
||||
export type V2SessionPromptData = {
|
||||
body: {
|
||||
id?: string
|
||||
@@ -11694,6 +11789,10 @@ export type V2SessionRevertStageErrors = {
|
||||
* MessageNotFoundError | SessionNotFoundError
|
||||
*/
|
||||
404: MessageNotFoundError | SessionNotFoundError
|
||||
/**
|
||||
* SessionBusyError
|
||||
*/
|
||||
409: SessionBusyError
|
||||
/**
|
||||
* UnknownError
|
||||
*/
|
||||
@@ -11735,6 +11834,10 @@ export type V2SessionRevertClearErrors = {
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
/**
|
||||
* SessionBusyError
|
||||
*/
|
||||
409: SessionBusyError
|
||||
/**
|
||||
* UnknownError
|
||||
*/
|
||||
@@ -11774,6 +11877,10 @@ export type V2SessionRevertCommitErrors = {
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
/**
|
||||
* SessionBusyError
|
||||
*/
|
||||
409: SessionBusyError
|
||||
}
|
||||
|
||||
export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors]
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InvalidCursorError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
@@ -136,6 +137,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.rename",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
@@ -205,14 +222,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `Session ${error.operation} is not available yet`,
|
||||
service: `session.${error.operation}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -220,6 +229,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.revert.stage",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.stage", {
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.payload.messageID,
|
||||
files: ctx.payload.files,
|
||||
})
|
||||
return {
|
||||
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
||||
Effect.catchTag(
|
||||
@@ -239,6 +253,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.BusyError",
|
||||
(error) =>
|
||||
new SessionBusyError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session is busy: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
||||
@@ -259,6 +281,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.revert.clear",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
||||
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
@@ -268,6 +291,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.BusyError",
|
||||
(error) =>
|
||||
new SessionBusyError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session is busy: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
||||
@@ -288,6 +319,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.revert.commit",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.commit", { sessionID: ctx.params.sessionID })
|
||||
yield* session.revert.commit(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
@@ -297,6 +329,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.BusyError",
|
||||
(error) =>
|
||||
new SessionBusyError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session is busy: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -29,6 +30,7 @@ const applicationServices = LayerNode.group([
|
||||
httpClient,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
SubagentTool.node,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
Credential.node,
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+59
-44
@@ -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
|
||||
@@ -1102,6 +1114,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
||||
import { sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogIntegration } from "./dialog-integration"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useData } from "../context/data"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
|
||||
const models = createMemo(() => data.location.model.list() ?? [])
|
||||
|
||||
const showExtra = createMemo(() => connected() && !props.providerID)
|
||||
|
||||
@@ -29,21 +30,20 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
function toOptions(items: typeof favorites, category: string) {
|
||||
if (!showSections) return []
|
||||
return items.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID)
|
||||
if (!model) return []
|
||||
const provider = providers().get(model.providerID)
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: { providerID: provider.id, modelID: model.id },
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: provider?.name ?? model.providerID,
|
||||
category,
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
onSelect(provider.id, model.id)
|
||||
onSelect(model.providerID, model.id)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -58,80 +58,48 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
"Recent",
|
||||
)
|
||||
|
||||
const providerOptions = pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
||||
map(([model, info]) => ({
|
||||
value: { providerID: provider.id, modelID: model },
|
||||
title: info.name ?? model,
|
||||
releaseDate: info.release_date,
|
||||
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
onSelect(provider.id, model)
|
||||
},
|
||||
})),
|
||||
filter((option) => {
|
||||
if (!showSections) return true
|
||||
if (
|
||||
favorites.some(
|
||||
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||
)
|
||||
const modelOptions = sortModelOptions(
|
||||
models()
|
||||
.filter((model) => model.status !== "deprecated")
|
||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||
.map((model) => ({
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
onSelect(model.providerID, model.id)
|
||||
},
|
||||
}))
|
||||
.filter((option) => {
|
||||
if (!showSections) return true
|
||||
if (
|
||||
favorites.some(
|
||||
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||
)
|
||||
return false
|
||||
if (
|
||||
recents.some(
|
||||
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||
)
|
||||
)
|
||||
return false
|
||||
return true
|
||||
}),
|
||||
(options) => sortModelOptions(options, props.providerID !== undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
return false
|
||||
if (
|
||||
recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
|
||||
)
|
||||
return false
|
||||
return true
|
||||
}),
|
||||
props.providerID !== undefined,
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
? pipe(
|
||||
providers(),
|
||||
map((option) => ({
|
||||
...option,
|
||||
category: "Popular providers",
|
||||
})),
|
||||
take(6),
|
||||
)
|
||||
: []
|
||||
|
||||
if (needle) {
|
||||
return [
|
||||
...sortModelOptions(
|
||||
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
|
||||
false,
|
||||
),
|
||||
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
|
||||
]
|
||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||
})
|
||||
|
||||
const provider = createMemo(() =>
|
||||
props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
|
||||
)
|
||||
const provider = createMemo(() => (props.providerID ? providers().get(props.providerID) : undefined))
|
||||
|
||||
const title = createMemo(() => {
|
||||
const value = provider()
|
||||
@@ -142,8 +110,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
const list = local.model.variant.list()
|
||||
const cur = local.model.variant.selected()
|
||||
if (cur === "default" || (cur && list.includes(cur))) {
|
||||
const cur = local.model.variant.current()
|
||||
if (cur && list.includes(cur)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
@@ -160,9 +128,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
actions={[
|
||||
{
|
||||
command: "model.dialog.provider",
|
||||
title: connected() ? "Connect provider" : "View all providers",
|
||||
title: connected() ? "Connect integration" : "View all integrations",
|
||||
onTrigger() {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
dialog.replace(() => <DialogIntegration />)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -195,3 +163,7 @@ export function sortModelOptions<T extends { footer?: string; releaseDate: strin
|
||||
(option) => option.title,
|
||||
)
|
||||
}
|
||||
|
||||
function free(model: { cost: Array<{ input: number }> }) {
|
||||
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
|
||||
try {
|
||||
await sdk.client.v2.projectCopy.refresh(
|
||||
{ projectID, location: { directory: sdk.directory } },
|
||||
{ projectID, location: { directory: projectContext.instance.directory() || paths.cwd } },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
|
||||
@@ -224,7 +224,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const result = await sdk.client.v2.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: sdk.directory },
|
||||
location: { directory: projectContext.instance.directory() || paths.cwd },
|
||||
directory: selected.directory,
|
||||
force: false,
|
||||
})
|
||||
@@ -246,7 +246,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const forced = await sdk.client.v2.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: sdk.directory },
|
||||
location: { directory: projectContext.instance.directory() || paths.cwd },
|
||||
directory: selected.directory,
|
||||
force: true,
|
||||
})
|
||||
|
||||
@@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
description={
|
||||
{
|
||||
description={() =>
|
||||
({
|
||||
opencode: (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
@@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
}[props.providerID] ?? undefined
|
||||
})[props.providerID] ?? undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
|
||||
@@ -1,203 +1,55 @@
|
||||
import { createMemo, createResource, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import { useData } from "../context/data"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useProject } from "../context/project"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useLocal } from "../context/local"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
import { useEvent } from "../context/event"
|
||||
|
||||
type SessionListFilter = { scope?: "project"; path?: string }
|
||||
|
||||
export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
|
||||
const search = input.search?.trim()
|
||||
return {
|
||||
roots: true,
|
||||
limit: search ? 30 : 100,
|
||||
...(search ? { search } : {}),
|
||||
...input.filter,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDialogSessionList<T>(input: {
|
||||
search?: string
|
||||
filter: SessionListFilter
|
||||
list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
|
||||
}) {
|
||||
return input.list(createDialogSessionListQuery(input)).then(
|
||||
(result) => result.data,
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const event = useEvent()
|
||||
const local = useLocal()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [deleted, setDeleted] = createSignal(new Set<string>())
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||
|
||||
const [browseResults, { refetch: refetchBrowse }] = createResource(
|
||||
() => sync.session.query(),
|
||||
(filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }),
|
||||
)
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
(input) => {
|
||||
if (!input.query) return undefined
|
||||
return loadDialogSessionList({
|
||||
search: input.query,
|
||||
filter: input.filter,
|
||||
list: (query) => sdk.client.session.list(query),
|
||||
})
|
||||
},
|
||||
)
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query) return
|
||||
const response = await sdk.client.v2.session.list(
|
||||
{ search: query, limit: 50, order: "desc" },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return { query, sessions: response.data.data }
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => {
|
||||
const result = searchResults() ?? browseResults() ?? sync.data.session
|
||||
const synced = new Map(sync.data.session.map((session) => [session.id, session]))
|
||||
const ids = new Set(result.map((session) => session.id))
|
||||
const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => {
|
||||
if (!id || ids.has(id)) return []
|
||||
const session = synced.get(id)
|
||||
if (session) ids.add(id)
|
||||
return session ? [session] : []
|
||||
})
|
||||
const query = search().trim().toLowerCase()
|
||||
return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
|
||||
.filter((session) => !deleted().has(session.id))
|
||||
.filter((session) => !query || session.title.toLowerCase().includes(query))
|
||||
const query = search()
|
||||
if (!query) return data.session.list()
|
||||
const result = searchResults()
|
||||
return result?.query === query ? result.sessions : []
|
||||
})
|
||||
|
||||
onCleanup(
|
||||
event.on("session.deleted", (event) => {
|
||||
setDeleted((current) => new Set(current).add(event.properties.info.id))
|
||||
}),
|
||||
)
|
||||
|
||||
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||
const workspace = project.workspace.get(session.workspaceID!)
|
||||
const list = () => dialog.replace(() => <DialogSessionList />)
|
||||
const warp = async (selection: WorkspaceSelection) => {
|
||||
const workspaceID = await (async () => {
|
||||
if (selection.type === "none") return null
|
||||
if (selection.type === "existing") return selection.workspaceID
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
toast.show({
|
||||
title: "Failed to create workspace",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
toast.show({
|
||||
title: "Failed to create workspace",
|
||||
message: errorMessage(result?.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await project.workspace.sync()
|
||||
return workspace.id
|
||||
})()
|
||||
if (workspaceID === undefined) return
|
||||
await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID: session.workspaceID,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
copyChanges: false,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogSessionDeleteFailed
|
||||
session={session.title}
|
||||
workspace={workspace?.name ?? session.workspaceID!}
|
||||
onDone={list}
|
||||
onDelete={async () => {
|
||||
const current = currentSessionID()
|
||||
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.session.refresh()
|
||||
await refetchBrowse()
|
||||
if (search()) await refetch()
|
||||
if (info?.workspaceID === session.workspaceID) {
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
return true
|
||||
}}
|
||||
onRestore={() => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
||||
return sessionsList
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
}
|
||||
|
||||
const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
const first = quickSwitch1()
|
||||
const last = quickSwitch9()
|
||||
if (!first || !last) return undefined
|
||||
if (!first || !last) return
|
||||
return quickSwitchRange(first, last)
|
||||
})
|
||||
const quickSwitchFooterHints = createMemo(() => {
|
||||
@@ -207,149 +59,71 @@ export function DialogSessionList() {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => [x.id, x]),
|
||||
)
|
||||
|
||||
const searchResult = searchResults()
|
||||
const order = searchResult ? orderByRecency(sessions()) : browseOrder()
|
||||
const current = currentSessionID()
|
||||
const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
|
||||
|
||||
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||
const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session]))
|
||||
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
|
||||
function buildOption(id: string, category: string) {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
const directory = x.path
|
||||
? x.directory.endsWith(x.path)
|
||||
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
|
||||
: undefined
|
||||
: x.directory
|
||||
const footer =
|
||||
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy" || status?.type === "retry"
|
||||
const slot = slotByID.get(x.id)
|
||||
const gutter = isWorking
|
||||
? () => <Spinner />
|
||||
: slot !== undefined
|
||||
? () => <text fg={theme.accent}>{slot}</text>
|
||||
: undefined
|
||||
const option = (session: SessionV2Info, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const slot = slotByID.get(session.id)
|
||||
return {
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
title: session.title,
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
gutter,
|
||||
gutter:
|
||||
data.session.status(session.id) === "running"
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.accent}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = displayOrder
|
||||
.filter((id) => !pinnedSet.has(id))
|
||||
.map((id) => {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
const label = new Date(x.time.updated).toDateString()
|
||||
return buildOption(id, label === today ? "Today" : label)
|
||||
const remaining = sessions()
|
||||
.filter((session) => !session.parentID && !pinnedSet.has(session.id))
|
||||
.map((session) => {
|
||||
const date = new Date(session.time.updated).toDateString()
|
||||
return option(session, date === today ? "Today" : date)
|
||||
})
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
|
||||
return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining]
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
const unavailable = (feature: string) =>
|
||||
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
preserveSelection={true}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
route.navigate({ type: "session", sessionID: option.value })
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
},
|
||||
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const session = sessions().find((item) => item.id === option.value)
|
||||
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
|
||||
|
||||
try {
|
||||
const result = await sdk.client.session.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
if (result.error) {
|
||||
if (session?.workspaceID) {
|
||||
recover(session)
|
||||
} else {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete session",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (session?.workspaceID) {
|
||||
recover(session)
|
||||
} else {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete session",
|
||||
message: errorMessage(err),
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
if (status && status !== "connected") {
|
||||
await sync.session.refresh()
|
||||
}
|
||||
await refetchBrowse()
|
||||
if (search()) await refetch()
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
onTrigger: () => unavailable("Deleting"),
|
||||
},
|
||||
{
|
||||
command: "session.rename",
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
onTrigger: (option: { value: string }) =>
|
||||
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSync } from "../context/sync"
|
||||
import { createMemo } from "solid-js"
|
||||
import { type DialogContext, useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
interface DialogSessionRenameProps {
|
||||
session: string
|
||||
}
|
||||
|
||||
export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||
export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
const toast = useToast()
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title="Rename Session"
|
||||
value={session()?.title}
|
||||
title="Rename session"
|
||||
placeholder="Session title"
|
||||
value={props.currentTitle}
|
||||
onConfirm={(value) => {
|
||||
void sdk.client.session.update({
|
||||
sessionID: props.session,
|
||||
title: value,
|
||||
})
|
||||
dialog.clear()
|
||||
const title = value.trim()
|
||||
if (!title) return
|
||||
void sdk.client.v2.session
|
||||
.rename({ sessionID: props.sessionID, title }, { throwOnError: true })
|
||||
.then(() => dialog.clear())
|
||||
.catch((error) =>
|
||||
toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }),
|
||||
)
|
||||
}}
|
||||
onCancel={() => dialog.clear()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
DialogSessionRename.show = (dialog: DialogContext, sessionID: string, currentTitle?: string) =>
|
||||
dialog.replace(() => <DialogSessionRename sessionID={sessionID} currentTitle={currentTitle} />)
|
||||
|
||||
@@ -7,32 +7,22 @@ export function DialogVariant() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
title: "Default",
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(undefined)
|
||||
},
|
||||
const options = createMemo(() =>
|
||||
local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
...local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
})),
|
||||
]
|
||||
})
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.selected()}
|
||||
current={local.model.variant.current()}
|
||||
flat={true}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}) {
|
||||
const dir = input.sync.path.directory || input.sdk.directory
|
||||
const dir = input.sync.path.directory || process.cwd()
|
||||
try {
|
||||
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
|
||||
if (response.error) throw response.error
|
||||
|
||||
@@ -400,15 +400,15 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
const agents = createMemo(() => {
|
||||
return sync.data.agent
|
||||
return (data.location.agent.list() ?? [])
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map(
|
||||
(agent): AutocompleteOption => ({
|
||||
display: "@" + agent.name,
|
||||
display: "@" + agent.id,
|
||||
onSelect: () => {
|
||||
insertPart(agent.name, {
|
||||
insertPart(agent.id, {
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
name: agent.id,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
|
||||
@@ -40,11 +40,10 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { formatDuration } from "../../util/format"
|
||||
import { createColors, createFrames } from "../../ui/spinner"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { DialogIntegration } from "../dialog-integration"
|
||||
import { useConnected } from "../use-connected"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
@@ -56,6 +55,7 @@ import { useTuiConfig } from "../../config"
|
||||
import { usePromptWorkspace } from "./workspace"
|
||||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -153,10 +153,17 @@ export function Prompt(props: PromptProps) {
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
const activeSubagents = createMemo(() =>
|
||||
data.session
|
||||
.list()
|
||||
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
|
||||
.length,
|
||||
)
|
||||
const history = usePromptHistory()
|
||||
const stash = usePromptStash()
|
||||
const keymap = useOpencodeKeymap()
|
||||
@@ -207,6 +214,7 @@ export function Prompt(props: PromptProps) {
|
||||
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const connected = useConnected()
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function promptModelWarning() {
|
||||
@@ -215,8 +223,8 @@ export function Prompt(props: PromptProps) {
|
||||
message: "Connect a provider to send prompts",
|
||||
duration: 3000,
|
||||
})
|
||||
if (sync.data.provider.length === 0) {
|
||||
dialog.replace(() => <DialogProviderConnect />)
|
||||
if (!connected()) {
|
||||
dialog.replace(() => <DialogIntegration />)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +241,7 @@ export function Prompt(props: PromptProps) {
|
||||
event.on("tui.prompt.append", (evt, { workspace }) => {
|
||||
if (workspace !== project.workspace.current()) return
|
||||
if (!input || input.isDestroyed) return
|
||||
input.insertText(evt.properties.text)
|
||||
input.insertText(evt.data.text)
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
@@ -303,28 +311,20 @@ export function Prompt(props: PromptProps) {
|
||||
),
|
||||
)
|
||||
|
||||
// Initialize agent/model/variant from last user message when session changes
|
||||
// Initialize agent/model/variant from the durable V2 Session state.
|
||||
let syncedSessionID: string | undefined
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID
|
||||
const msg = lastUserMessage()
|
||||
|
||||
if (sessionID !== syncedSessionID) {
|
||||
if (!sessionID || !msg) return
|
||||
|
||||
syncedSessionID = sessionID
|
||||
|
||||
// Only set agent if it's a primary agent (not a subagent)
|
||||
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
|
||||
if (msg.agent && isPrimaryAgent) {
|
||||
// Keep command line --agent if specified.
|
||||
if (!args.agent) local.agent.set(msg.agent)
|
||||
if (msg.model) {
|
||||
local.model.set(msg.model)
|
||||
local.model.variant.set(msg.model.variant)
|
||||
}
|
||||
}
|
||||
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
|
||||
const session = data.session.get(sessionID)
|
||||
if (!session) return
|
||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||
if (agent && !args.agent) local.agent.set(agent.id)
|
||||
if (session.model) {
|
||||
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
|
||||
local.model.variant.set(session.model.variant)
|
||||
}
|
||||
syncedSessionID = sessionID
|
||||
})
|
||||
|
||||
const promptCommands = createMemo(() =>
|
||||
@@ -389,7 +389,7 @@ export function Prompt(props: PromptProps) {
|
||||
name: "session.interrupt",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
enabled: status().type !== "idle",
|
||||
enabled: status() === "running",
|
||||
run: () => {
|
||||
if (auto()?.visible) return
|
||||
if (!input.focused) return
|
||||
@@ -407,7 +407,7 @@ export function Prompt(props: PromptProps) {
|
||||
}, 5000)
|
||||
|
||||
if (store.interrupt >= 2) {
|
||||
void sdk.client.session.abort({
|
||||
void sdk.client.v2.session.interrupt({
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
setStore("interrupt", 0)
|
||||
@@ -983,6 +983,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
if (sessionID == null) {
|
||||
const selectedWorkspace = workspace.selection()
|
||||
@@ -992,10 +993,9 @@ export function Prompt(props: PromptProps) {
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
|
||||
const res = await sdk.client.session.create({
|
||||
directory,
|
||||
workspace: workspaceID,
|
||||
agent: agent.name,
|
||||
const res = await sdk.client.v2.session.create({
|
||||
location: directory ? { directory, workspaceID } : undefined,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
@@ -1005,8 +1005,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
if (res.error) {
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
console.log("Creating a session failed:", res.error)
|
||||
|
||||
toast.show({
|
||||
message: "Creating a session failed. Open console for more details.",
|
||||
variant: "error",
|
||||
@@ -1015,7 +1013,8 @@ export function Prompt(props: PromptProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
sessionID = res.data.id
|
||||
sessionID = res.data.data.id
|
||||
session = res.data.data
|
||||
}
|
||||
|
||||
const inputText = expandTrackedPastedText(
|
||||
@@ -1055,7 +1054,7 @@ export function Prompt(props: PromptProps) {
|
||||
move.startSubmit()
|
||||
void sdk.client.session.shell({
|
||||
sessionID,
|
||||
agent: agent.name,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
modelID: selectedModel.modelID,
|
||||
@@ -1079,39 +1078,79 @@ export function Prompt(props: PromptProps) {
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
arguments: args,
|
||||
agent: agent.name,
|
||||
agent: agent.id,
|
||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||
variant,
|
||||
parts: nonTextParts.filter((x) => x.type === "file"),
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
sdk.client.session
|
||||
.prompt(
|
||||
if (!session) {
|
||||
await data.session.refresh(sessionID)
|
||||
session = data.session.get(sessionID)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== selectedModel.providerID ||
|
||||
session.model.id !== selectedModel.modelID ||
|
||||
session.model.variant !== variant
|
||||
) {
|
||||
await sdk.client.v2.session.switchModel(
|
||||
{
|
||||
sessionID,
|
||||
...selectedModel,
|
||||
agent: agent.name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts: [
|
||||
...editorParts,
|
||||
{
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts,
|
||||
],
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
title: "Failed to send prompt",
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const revertResult = await sdk.client.v2.session.revert.commit({ sessionID })
|
||||
if (revertResult.error) {
|
||||
toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
}
|
||||
const result = await sdk.client.v2.session.prompt({
|
||||
sessionID,
|
||||
prompt: {
|
||||
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
||||
files: nonTextParts.flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
source: part.source
|
||||
? {
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
agents: nonTextParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
},
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
if (editorParts.length > 0) editor.markSelectionSent()
|
||||
}
|
||||
history.append({
|
||||
@@ -1285,7 +1324,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (store.mode === "shell") return theme.primary
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return theme.border
|
||||
return local.agent.color(agent.name)
|
||||
return local.agent.color(agent.id)
|
||||
})
|
||||
|
||||
const showVariant = createMemo(() => {
|
||||
@@ -1316,10 +1355,10 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent =
|
||||
status().type !== "idle"
|
||||
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
|
||||
status() === "running"
|
||||
? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
|
||||
: local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
@@ -1440,7 +1479,7 @@ export function Prompt(props: PromptProps) {
|
||||
{(agent) => (
|
||||
<>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -1502,78 +1541,20 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<Switch>
|
||||
<Match when={status().type !== "idle"}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
void DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
}
|
||||
}
|
||||
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const duration = formatDuration(seconds())
|
||||
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={activeSubagents()}>
|
||||
{(count) => (
|
||||
<Spinner color={theme.textMuted}>
|
||||
{count()} active subagent{count() === 1 ? "" : "s"}
|
||||
</Spinner>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
@@ -1634,41 +1615,39 @@ export function Prompt(props: PromptProps) {
|
||||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<Autocomplete
|
||||
|
||||
@@ -40,7 +40,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
const result = await sdk.client.v2.projectCopy.create(
|
||||
{
|
||||
projectID,
|
||||
location: { directory: sdk.directory },
|
||||
location: { directory: project.instance.directory() || paths.cwd },
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
name: generated.data.name,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting(props: { attempt: number; error?: string }) {
|
||||
const theme = useTheme().theme
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={10_000}
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
backgroundColor={theme.background}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<box width={54} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
|
||||
<text fg={theme.text}>Connection lost</text>
|
||||
<Spinner color={theme.textMuted}>Reconnecting to server...</Spinner>
|
||||
<text fg={theme.textMuted}>Attempt {props.attempt}</text>
|
||||
<Show when={props.error}>
|
||||
<text fg={theme.error} wrapMode="word">
|
||||
{props.error}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useData } from "../context/data"
|
||||
import { useSync } from "../context/sync"
|
||||
|
||||
export function useConnected() {
|
||||
const data = useData()
|
||||
const sync = useSync()
|
||||
return createMemo(() =>
|
||||
sync.data.provider.some(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0),
|
||||
),
|
||||
return createMemo(
|
||||
() =>
|
||||
(data.location.integration.list() ?? []).some((integration) => integration.connections.length > 0) ||
|
||||
sync.data.console_state.consoleManagedProviders.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const Definitions = {
|
||||
model_cycle_favorite: keybind("none", "Next favorite model"),
|
||||
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
|
||||
mcp_list: keybind("none", "List MCP servers"),
|
||||
provider_connect: keybind("none", "Connect provider"),
|
||||
provider_connect: keybind("none", "Connect integration"),
|
||||
console_org_switch: keybind("none", "Switch console organization"),
|
||||
agent_list: keybind("<leader>a", "List agents"),
|
||||
agent_cycle: keybind("tab", "Next agent"),
|
||||
|
||||
@@ -21,8 +21,9 @@ import type {
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useEvent } from "./event"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
@@ -37,6 +38,7 @@ type LocationData = {
|
||||
type Data = {
|
||||
session: {
|
||||
info: Record<string, SessionV2Info>
|
||||
status: Record<string, DataSessionStatus>
|
||||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
@@ -61,6 +63,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const [store, setStore] = createStore<Data>({
|
||||
session: {
|
||||
info: {},
|
||||
status: {},
|
||||
message: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
@@ -72,35 +75,37 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const events = useEvent()
|
||||
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||
directory: sdk.directory ?? process.cwd(),
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
"session",
|
||||
"message",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []))
|
||||
fn((draft[sessionID] ??= []), index(sessionID))
|
||||
}),
|
||||
)
|
||||
},
|
||||
prepend(messages: SessionMessage[], item: SessionMessage) {
|
||||
if (messages.some((existing) => existing.id === item.id)) return
|
||||
messages.unshift(item)
|
||||
append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
|
||||
if (index.has(item.id)) return
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
activeAssistant(messages: SessionMessage[]) {
|
||||
const item = messages.find((item) => item.type === "assistant" && !item.time.completed)
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessage[], messageID: string) {
|
||||
const item = messages.find((item) => item.type === "assistant" && item.id === messageID)
|
||||
assistant(messages: SessionMessage[], index: Map<string, number>, messageID: string) {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : messages[position]
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
activeShell(messages: SessionMessage[], callID: string) {
|
||||
const item = messages.find((item) => item.type === "shell" && item.callID === callID)
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.callID === callID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
@@ -121,8 +126,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
function index(sessionID: string) {
|
||||
const existing = messageIndex.get(sessionID)
|
||||
if (existing) return existing
|
||||
const created = new Map<string, number>()
|
||||
messageIndex.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function handleEvent(event: V2Event) {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
void result.session.refresh(event.data.sessionID)
|
||||
break
|
||||
case "catalog.updated":
|
||||
void Promise.all([
|
||||
result.location.model.refresh(event.location),
|
||||
@@ -130,8 +146,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
])
|
||||
break
|
||||
case "session.next.agent.switched":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
@@ -140,8 +158,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.model.switched":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
@@ -149,9 +169,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.renamed":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.next.prompted": {
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "user",
|
||||
text: event.data.prompt.text,
|
||||
@@ -165,8 +190,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "session.next.prompt.admitted":
|
||||
break
|
||||
case "session.next.context.updated":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
@@ -175,8 +200,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.synthetic":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "synthetic",
|
||||
sessionID: event.data.sessionID,
|
||||
@@ -186,8 +211,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "shell",
|
||||
callID: event.data.callID,
|
||||
@@ -198,7 +224,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.shell.ended":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.activeShell(draft, event.data.callID)
|
||||
if (!match) return
|
||||
match.output = event.data.output
|
||||
@@ -206,11 +233,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.step.started":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
if (draft.some((message) => message.id === event.data.assistantMessageID)) return
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
if (index.has(event.data.assistantMessageID)) return
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
|
||||
message.prepend(draft, {
|
||||
message.append(draft, index, {
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
@@ -222,8 +250,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.step.ended":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
|
||||
setStore("session", "status", event.data.sessionID, event.data.finish === "tool-calls" ? "running" : "idle")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.data.timestamp
|
||||
currentAssistant.finish = event.data.finish
|
||||
@@ -234,8 +263,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.step.failed":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.data.timestamp
|
||||
currentAssistant.finish = "error"
|
||||
@@ -243,8 +273,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.assistant(draft, event.data.assistantMessageID)?.content.push({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
id: event.data.textID,
|
||||
text: "",
|
||||
@@ -252,20 +282,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.assistant(draft, event.data.assistantMessageID)?.content.push({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
@@ -275,20 +305,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
|
||||
if (match?.state.status === "pending") match.state.input += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
|
||||
if (match?.state.status === "pending") match.state.input = event.data.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
|
||||
if (!match) return
|
||||
match.time.ran = event.data.timestamp
|
||||
match.provider = event.data.provider
|
||||
@@ -296,16 +326,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.progress":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
})
|
||||
break
|
||||
case "session.next.tool.success":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
@@ -323,8 +353,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.failed":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
|
||||
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
status: "error",
|
||||
@@ -343,43 +373,56 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.assistant(draft, event.data.assistantMessageID)?.content.push({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
time: { created: event.data.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, event.data.assistantMessageID),
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.reasoningID,
|
||||
)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, event.data.assistantMessageID),
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
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
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
case "session.next.compaction.started":
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
break
|
||||
case "session.next.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
|
||||
break
|
||||
case "session.next.revert.cleared":
|
||||
case "session.next.revert.committed":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
type: "compaction",
|
||||
reason: event.data.reason,
|
||||
@@ -389,6 +432,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
break
|
||||
case "permission.v2.asked":
|
||||
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
|
||||
setStore("session", "permission", event.data.sessionID, [
|
||||
...(store.session.permission[event.data.sessionID] ?? []),
|
||||
event.data,
|
||||
])
|
||||
break
|
||||
case "permission.v2.replied":
|
||||
setStore(
|
||||
"session",
|
||||
"permission",
|
||||
event.data.sessionID,
|
||||
(store.session.permission[event.data.sessionID] ?? []).filter(
|
||||
(request) => request.id !== event.data.requestID,
|
||||
),
|
||||
)
|
||||
break
|
||||
case "question.v2.asked":
|
||||
if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
|
||||
setStore("session", "question", event.data.sessionID, [
|
||||
...(store.session.question[event.data.sessionID] ?? []),
|
||||
event.data,
|
||||
])
|
||||
break
|
||||
case "question.v2.replied":
|
||||
case "question.v2.rejected":
|
||||
setStore(
|
||||
"session",
|
||||
"question",
|
||||
event.data.sessionID,
|
||||
(store.session.question[event.data.sessionID] ?? []).filter(
|
||||
(request) => request.id !== event.data.requestID,
|
||||
),
|
||||
)
|
||||
break
|
||||
case "reference.updated":
|
||||
void result.location.reference.refresh()
|
||||
break
|
||||
@@ -402,33 +480,61 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsub = events.subscribe((event, metadata) => {
|
||||
handleEvent({
|
||||
...event,
|
||||
data: event.properties,
|
||||
location: { directory: metadata.directory, workspaceID: metadata.workspace },
|
||||
} as V2Event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
const result = {
|
||||
on: sdk.event.on,
|
||||
listen: sdk.event.listen,
|
||||
connection: {
|
||||
status() {
|
||||
return sdk.connection.status()
|
||||
},
|
||||
attempt() {
|
||||
return sdk.connection.attempt()
|
||||
},
|
||||
error() {
|
||||
return sdk.connection.error()
|
||||
},
|
||||
},
|
||||
session: {
|
||||
list() {
|
||||
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
},
|
||||
get(sessionID: string) {
|
||||
return store.session.info[sessionID]
|
||||
},
|
||||
status(sessionID: string) {
|
||||
return store.session.status[sessionID] ?? "idle"
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
|
||||
setStore("session", "info", sessionID, result.data.data)
|
||||
},
|
||||
message: {
|
||||
ids(sessionID: string) {
|
||||
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.message[sessionID]
|
||||
return store.session.message[sessionID] ?? []
|
||||
},
|
||||
get(sessionID: string, messageID: string) {
|
||||
const messages = store.session.message[sessionID]
|
||||
const position = messageIndex.get(sessionID)?.get(messageID)
|
||||
return position === undefined ? undefined : messages?.[position]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
|
||||
setStore("session", "message", sessionID, result.data.data)
|
||||
setStore("session", "message", sessionID, [])
|
||||
messageIndex.set(sessionID, new Map())
|
||||
const response = await sdk.client.v2.session.messages(
|
||||
{ sessionID, limit: 200, order: "desc" },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const loaded = response.data.data.toReversed()
|
||||
const live = store.session.message[sessionID] ?? []
|
||||
const liveByID = new Map(live.map((message) => [message.id, message]))
|
||||
const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live]
|
||||
.filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index)
|
||||
.toSorted((a, b) => a.time.created - b.time.created)
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, messages)
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
@@ -479,7 +585,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "agent", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], agent: result.data.data })
|
||||
},
|
||||
},
|
||||
command: {
|
||||
@@ -489,7 +595,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "command", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], command: result.data.data })
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
@@ -502,7 +608,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "integration", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], integration: result.data.data })
|
||||
},
|
||||
},
|
||||
model: {
|
||||
@@ -512,7 +618,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "model", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], model: result.data.data })
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
@@ -522,7 +628,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "provider", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], provider: result.data.data })
|
||||
},
|
||||
},
|
||||
reference: {
|
||||
@@ -532,7 +638,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "reference", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], reference: result.data.data })
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
@@ -542,14 +648,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "skill", result.data.data)
|
||||
setStore("location", key, { ...store.location[key], skill: result.data.data })
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void Promise.allSettled([
|
||||
async function bootstrap() {
|
||||
const settled = await Promise.allSettled([
|
||||
sdk.client.v2.session
|
||||
.list({ limit: 50, order: "desc" }, { throwOnError: true })
|
||||
.then((response) =>
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of response.data.data) draft[session.id] = session
|
||||
}),
|
||||
),
|
||||
),
|
||||
sdk.client.v2.session.active({ throwOnError: true }).then((response) =>
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])),
|
||||
),
|
||||
),
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
@@ -558,11 +682,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
result.location.reference.refresh(),
|
||||
result.location.command.refresh(),
|
||||
result.location.skill.refresh(),
|
||||
]).then((settled) => {
|
||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||
console.error("Failed to refresh default location data", failure.reason)
|
||||
})
|
||||
})
|
||||
])
|
||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||
console.error("Failed to refresh default location data", failure.reason)
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
sdk.event.listen(({ details }) => {
|
||||
handleEvent(details)
|
||||
if (details.type === "server.connected") void bootstrap()
|
||||
}),
|
||||
)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type EventMetadata = {
|
||||
directory: string
|
||||
directory: string | undefined
|
||||
workspace: string | undefined
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const sdk = useSDK()
|
||||
|
||||
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
|
||||
return sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "sync") {
|
||||
return
|
||||
}
|
||||
|
||||
handler(event.payload, { directory: event.directory, workspace: event.workspace })
|
||||
function subscribe(handler: (event: V2Event, metadata: EventMetadata) => void) {
|
||||
return sdk.event.listen(({ details }) => {
|
||||
if (details.type === "server.connected") return
|
||||
handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID })
|
||||
})
|
||||
}
|
||||
|
||||
function on<T extends Event["type"]>(
|
||||
function on<T extends V2Event["type"]>(
|
||||
type: T,
|
||||
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
|
||||
handler: (event: Extract<V2Event, { type: T }>, metadata: EventMetadata) => void,
|
||||
) {
|
||||
return subscribe((event: Event, metadata: EventMetadata) => {
|
||||
if (event.type !== type) return
|
||||
handler(event as Extract<Event, { type: T }>, metadata)
|
||||
return sdk.event.on(type, (event) => {
|
||||
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
|
||||
export type LocalTheme = {
|
||||
secondary: RGBA
|
||||
@@ -51,6 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const theme = useTheme().theme
|
||||
@@ -58,8 +60,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const paths = useTuiPaths()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
const provider = sync.data.provider.find((item) => item.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
return !!data.location.model
|
||||
.list()
|
||||
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
||||
@@ -71,8 +74,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const agents = createMemo(() => sync.data.agent.filter((agent) => agent.mode !== "subagent" && !agent.hidden))
|
||||
const visibleAgents = createMemo(() => sync.data.agent.filter((agent) => !agent.hidden))
|
||||
const agents = createMemo(() =>
|
||||
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
||||
)
|
||||
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
|
||||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
})
|
||||
@@ -90,30 +95,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
|
||||
return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0)
|
||||
},
|
||||
set(name: string) {
|
||||
if (!agents().some((x) => x.name === name))
|
||||
set(id: string) {
|
||||
if (!agents().some((agent) => agent.id === id))
|
||||
return toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent not found: ${name}`,
|
||||
message: `Agent not found: ${id}`,
|
||||
duration: 3000,
|
||||
})
|
||||
setAgentStore("current", name)
|
||||
setAgentStore("current", id)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
batch(() => {
|
||||
const current = this.current()
|
||||
if (!current) return
|
||||
let next = agents().findIndex((x) => x.name === current.name) + direction
|
||||
let next = agents().findIndex((agent) => agent.id === current.id) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setAgentStore("current", value.name)
|
||||
setAgentStore("current", value.id)
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const index = visibleAgents().findIndex((x) => x.name === name)
|
||||
color(id: string) {
|
||||
const index = visibleAgents().findIndex((agent) => agent.id === id)
|
||||
if (index === -1) return colors()[0]
|
||||
const agent = visibleAgents()[index]
|
||||
|
||||
@@ -218,15 +223,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const provider = sync.data.provider[0]
|
||||
if (!provider) return undefined
|
||||
const defaultModel = sync.data.provider_default[provider.id]
|
||||
const firstModel = Object.values(provider.models)[0]
|
||||
const model = defaultModel ?? firstModel?.id
|
||||
const model = data.location.model.list()?.[0]
|
||||
if (!model) return undefined
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -234,8 +235,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const a = agent.current()
|
||||
return (
|
||||
getFirstValidModel(
|
||||
() => a && modelStore.model[a.name],
|
||||
() => a && a.model,
|
||||
() => a && modelStore.model[a.id],
|
||||
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
||||
fallbackModel,
|
||||
) ?? undefined
|
||||
)
|
||||
@@ -261,12 +262,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
reasoning: false,
|
||||
}
|
||||
}
|
||||
const provider = sync.data.provider.find((item) => item.id === value.providerID)
|
||||
const info = provider?.models[value.modelID]
|
||||
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
|
||||
const info = data.location.model
|
||||
.list()
|
||||
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||
return {
|
||||
provider: provider?.name ?? value.providerID,
|
||||
model: info?.name ?? value.modelID,
|
||||
reasoning: info?.capabilities?.reasoning ?? false,
|
||||
reasoning: info?.variants.length !== 0,
|
||||
}
|
||||
}),
|
||||
cycle(direction: 1 | -1) {
|
||||
@@ -282,7 +285,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!val) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.name, { ...val })
|
||||
setModelStore("model", a.id, { ...val })
|
||||
},
|
||||
cycleFavorite(direction: 1 | -1) {
|
||||
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
||||
@@ -310,7 +313,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!next) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.name, { ...next })
|
||||
setModelStore("model", a.id, { ...next })
|
||||
setModelStore("recent", recentModels(next, modelStore.recent))
|
||||
save()
|
||||
},
|
||||
@@ -326,7 +329,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.name, model)
|
||||
setModelStore("model", a.id, model)
|
||||
if (options?.recent) {
|
||||
setModelStore("recent", recentModels(model, modelStore.recent))
|
||||
save()
|
||||
@@ -361,21 +364,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const m = currentModel()
|
||||
if (!m) return undefined
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
return modelStore.variant[key]
|
||||
return modelStore.variant[key] ?? "default"
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (!v) return undefined
|
||||
if (!this.list().includes(v)) return undefined
|
||||
return v
|
||||
if (v !== "default" && this.list().includes(v)) return v
|
||||
const m = currentModel()!
|
||||
return (
|
||||
data.location.model
|
||||
.list()
|
||||
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)?.request.variant ?? "default"
|
||||
)
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
if (!m) return []
|
||||
const provider = sync.data.provider.find((item) => item.id === m.providerID)
|
||||
const info = provider?.models[m.modelID]
|
||||
if (!info?.variants) return []
|
||||
return Object.keys(info.variants)
|
||||
const info = data.location.model
|
||||
.list()
|
||||
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||
return info?.variants.map((variant) => variant.id) ?? []
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
@@ -394,7 +402,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
const index = variants.indexOf(current)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(undefined)
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
@@ -466,7 +474,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
prune(evt.properties.info.id)
|
||||
prune(evt.data.info.id)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -521,10 +529,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (!value?.model) return
|
||||
if (isModelValid(value.model)) return
|
||||
if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return
|
||||
toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`,
|
||||
duration: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch } from "solid-js"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -16,7 +16,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
directory: process.cwd(),
|
||||
} satisfies Path
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
@@ -67,11 +67,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
})
|
||||
}
|
||||
|
||||
sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "workspace.status") {
|
||||
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
|
||||
}
|
||||
})
|
||||
onCleanup(
|
||||
sdk.event.on("workspace.status", (event) => {
|
||||
setStore("workspace", "status", event.data.workspaceID, event.data.status)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
data: store,
|
||||
|
||||
+127
-133
@@ -1,151 +1,145 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
|
||||
}
|
||||
export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
|
||||
type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: {
|
||||
url: string
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
}) => {
|
||||
init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
|
||||
const abort = new AbortController()
|
||||
let sse: AbortController | undefined
|
||||
|
||||
function createSDK() {
|
||||
return createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
})
|
||||
}
|
||||
|
||||
let sdk = createSDK()
|
||||
|
||||
const handlers = new Set<(event: GlobalEvent) => void>()
|
||||
const emitter = {
|
||||
emit(_type: "event", event: GlobalEvent) {
|
||||
for (const handler of handlers) handler(event)
|
||||
},
|
||||
on(_type: "event", handler: (event: GlobalEvent) => void) {
|
||||
handlers.add(handler)
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let queue: GlobalEvent[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
const retryDelay = 1000
|
||||
const maxRetryDelay = 30000
|
||||
|
||||
const flush = () => {
|
||||
if (queue.length === 0) return
|
||||
const events = queue
|
||||
queue = []
|
||||
timer = undefined
|
||||
last = Date.now()
|
||||
// Batch all event emissions so all store updates result in a single render
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
emitter.emit("event", event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEvent = (event: GlobalEvent) => {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
|
||||
if (timer) return
|
||||
// If we just flushed recently (within 16ms), batch this with future events
|
||||
// Otherwise, process immediately to avoid latency
|
||||
if (elapsed < 16) {
|
||||
timer = setTimeout(flush, 16)
|
||||
return
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
function startSSE() {
|
||||
sse?.abort()
|
||||
const ctrl = new AbortController()
|
||||
sse = ctrl
|
||||
;(async () => {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
|
||||
const events = await sdk.global.event({
|
||||
signal: ctrl.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
})
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// Start syncing workspaces, it's important to do this after
|
||||
// we've started listening to events
|
||||
await sdk.sync.start().catch(() => {})
|
||||
}
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) flush()
|
||||
attempt += 1
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
|
||||
// Exponential backoff
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (props.events) {
|
||||
const unsub = await props.events.subscribe(handleEvent)
|
||||
onCleanup(unsub)
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// Start syncing workspaces, it's important to do this after
|
||||
// we've started listening to events
|
||||
await sdk.sync.start().catch(() => {})
|
||||
}
|
||||
} else {
|
||||
startSSE()
|
||||
}
|
||||
let client = props.client
|
||||
const events = createGlobalEmitter<SDKEventMap>()
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: SDKConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
let pending: Promise<void> | undefined
|
||||
|
||||
function start() {
|
||||
stream?.abort()
|
||||
const controller = new AbortController()
|
||||
const current = client
|
||||
let connected!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
connected = resolve
|
||||
})
|
||||
stream = controller
|
||||
void (async () => {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const connection = new AbortController()
|
||||
const cancel = () => connection.abort(controller.signal.reason)
|
||||
const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
controller.signal.addEventListener("abort", cancel, { once: true })
|
||||
const error = await (async () => {
|
||||
const response = await current.v2.event.subscribe({
|
||||
signal: connection.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
throwOnError: true,
|
||||
})
|
||||
const iterator = response.stream[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (first.done)
|
||||
return connection.signal.reason instanceof Error
|
||||
? connection.signal.reason
|
||||
: new Error("Event stream disconnected")
|
||||
clearTimeout(timeout)
|
||||
attempt = 0
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
events.emit(first.value.type, first.value)
|
||||
connected()
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (event.done) return new Error("Event stream disconnected")
|
||||
events.emit(event.value.type, event.value)
|
||||
}
|
||||
})()
|
||||
.catch((error) => error)
|
||||
.finally(() => {
|
||||
clearTimeout(timeout)
|
||||
controller.signal.removeEventListener("abort", cancel)
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
attempt += 1
|
||||
setConnection({
|
||||
status: "reconnecting",
|
||||
attempt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
await wait(250, controller.signal)
|
||||
}
|
||||
})()
|
||||
return ready
|
||||
}
|
||||
|
||||
const reload = props.reload
|
||||
? () => {
|
||||
if (pending) return pending
|
||||
pending = Promise.resolve()
|
||||
.then(props.reload)
|
||||
.then(async (next) => {
|
||||
client = next
|
||||
if (!abort.signal.aborted) await start()
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
: undefined
|
||||
|
||||
onMount(() => void start())
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
sse?.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
handlers.clear()
|
||||
stream?.abort()
|
||||
events.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
get client() {
|
||||
return sdk
|
||||
return client
|
||||
},
|
||||
directory: props.directory,
|
||||
event: emitter,
|
||||
fetch: props.fetch ?? fetch,
|
||||
url: props.url,
|
||||
event: {
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
},
|
||||
connection: {
|
||||
status() {
|
||||
return connection.status
|
||||
},
|
||||
attempt() {
|
||||
return connection.attempt
|
||||
},
|
||||
error() {
|
||||
return connection.error
|
||||
},
|
||||
},
|
||||
reload,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,55 +1,33 @@
|
||||
import type {
|
||||
Message,
|
||||
Agent,
|
||||
Provider,
|
||||
Session,
|
||||
Part,
|
||||
Config,
|
||||
Todo,
|
||||
Command,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
McpResource,
|
||||
FormatterStatus,
|
||||
SessionStatus,
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
VcsInfo,
|
||||
SnapshotFileDiff,
|
||||
Config,
|
||||
ConsoleState,
|
||||
FormatterStatus,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Provider,
|
||||
ProviderAuthMethod,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "./project"
|
||||
import { useEvent } from "./event"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTuiStartup } from "./runtime"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import { useKV } from "./kv"
|
||||
import { useProject } from "./project"
|
||||
|
||||
const emptyConsoleState: ConsoleState = {
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
}
|
||||
|
||||
function search<T>(items: T[], target: string, key: (item: T) => string) {
|
||||
let left = 0
|
||||
let right = items.length - 1
|
||||
while (left <= right) {
|
||||
const middle = Math.floor((left + right) / 2)
|
||||
const value = key(items[middle])
|
||||
if (value === target) return { found: true, index: middle }
|
||||
if (value < target) left = middle + 1
|
||||
else right = middle - 1
|
||||
}
|
||||
return { found: false, index: left }
|
||||
}
|
||||
|
||||
export const {
|
||||
context: SyncContext,
|
||||
use: useSync,
|
||||
@@ -57,8 +35,7 @@ export const {
|
||||
} = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const startup = useTuiStartup()
|
||||
const kv = useKV()
|
||||
const project = useProject()
|
||||
const [store, setStore] = createStore<{
|
||||
status: "loading" | "partial" | "complete"
|
||||
provider: Provider[]
|
||||
@@ -71,39 +48,23 @@ export const {
|
||||
provider_auth: Record<string, ProviderAuthMethod[]>
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
question: Record<string, QuestionRequest[]>
|
||||
config: Config
|
||||
session: Session[]
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_diff: {
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
session_diff: Record<string, SnapshotFileDiff[]>
|
||||
todo: Record<string, Todo[]>
|
||||
message: Record<string, Message[]>
|
||||
part: Record<string, Part[]>
|
||||
lsp: LspStatus[]
|
||||
mcp: {
|
||||
[key: string]: McpStatus
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
}
|
||||
mcp: Record<string, McpStatus>
|
||||
mcp_resource: Record<string, McpResource>
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
}>({
|
||||
status: "complete",
|
||||
provider: [],
|
||||
provider_default: {},
|
||||
provider_next: {
|
||||
all: [],
|
||||
default: {},
|
||||
@@ -114,16 +75,12 @@ export const {
|
||||
experimentalBackgroundSubagents: false,
|
||||
},
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
permission: {},
|
||||
question: {},
|
||||
command: [],
|
||||
provider: [],
|
||||
provider_default: {},
|
||||
config: {},
|
||||
session: [],
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: {},
|
||||
@@ -135,521 +92,32 @@ export const {
|
||||
vcs: undefined,
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
const syncingSessions = new Map<string, Promise<void>>()
|
||||
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
|
||||
const touchMessage = (sessionID: string, messageID: string) => {
|
||||
hydratingSessions.get(sessionID)?.messages.add(messageID)
|
||||
}
|
||||
const touchPart = (sessionID: string, partID: string) => {
|
||||
hydratingSessions.get(sessionID)?.parts.add(partID)
|
||||
}
|
||||
|
||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
||||
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
|
||||
return {
|
||||
path: path
|
||||
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
|
||||
.replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
|
||||
function listSessions() {
|
||||
return sdk.client.session
|
||||
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
}
|
||||
|
||||
event.subscribe((event, { workspace }) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
void bootstrap()
|
||||
break
|
||||
case "permission.replied": {
|
||||
const requests = store.permission[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"permission",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
break
|
||||
}
|
||||
const match = search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("permission", request.sessionID, match.index, reconcile(request))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"permission",
|
||||
request.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 0, request)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const requests = store.question[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"question",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "question.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.question[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("question", request.sessionID, [request])
|
||||
break
|
||||
}
|
||||
const match = search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("question", request.sessionID, match.index, reconcile(request))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"question",
|
||||
request.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 0, request)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
|
||||
case "session.diff":
|
||||
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
||||
break
|
||||
|
||||
case "session.deleted": {
|
||||
const result = search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "session.updated": {
|
||||
const result = search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "session.next.moved": {
|
||||
const result = search(store.session, event.properties.sessionID, (s) => s.id)
|
||||
if (!result.found) break
|
||||
setStore(
|
||||
"session",
|
||||
result.index,
|
||||
produce((session) => {
|
||||
session.directory = event.properties.location.directory
|
||||
session.path = event.properties.subdirectory
|
||||
session.workspaceID = event.properties.location.workspaceID
|
||||
session.time.updated = event.properties.timestamp
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "session.status": {
|
||||
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||
break
|
||||
}
|
||||
|
||||
case "message.updated": {
|
||||
touchMessage(event.properties.info.sessionID, event.properties.info.id)
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
const updated = store.message[event.properties.info.sessionID]
|
||||
if (updated.length > 100) {
|
||||
const oldest = updated[0]
|
||||
batch(() => {
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.shift()
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"part",
|
||||
produce((draft) => {
|
||||
delete draft[oldest.id]
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
touchMessage(event.properties.sessionID, event.properties.messageID)
|
||||
const messages = store.message[event.properties.sessionID]
|
||||
const result = search(messages, event.properties.messageID, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
touchPart(event.properties.part.sessionID, event.properties.part.id)
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.delta": {
|
||||
const parts = store.part[event.properties.messageID]
|
||||
if (!parts) break
|
||||
const result = search(parts, event.properties.partID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
touchPart(event.properties.sessionID, event.properties.partID)
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.messageID,
|
||||
produce((draft) => {
|
||||
const part = draft[result.index]
|
||||
const field = event.properties.field as keyof typeof part
|
||||
const existing = part[field] as string | undefined
|
||||
;(part[field] as string) = (existing ?? "") + event.properties.delta
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.removed": {
|
||||
touchPart(event.properties.sessionID, event.properties.partID)
|
||||
const parts = store.part[event.properties.messageID]
|
||||
const result = search(parts, event.properties.partID, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "lsp.updated": {
|
||||
const workspace = project.workspace.current()
|
||||
void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
|
||||
break
|
||||
}
|
||||
|
||||
case "vcs.branch.updated": {
|
||||
if (workspace === project.workspace.current()) {
|
||||
setStore("vcs", { branch: event.properties.branch })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const exit = useExit()
|
||||
const args = useArgs()
|
||||
|
||||
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||
const fatal = input.fatal ?? true
|
||||
const workspace = project.workspace.current()
|
||||
const projectPromise = project.sync()
|
||||
const sessionListPromise = projectPromise.then(() => listSessions())
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||
const capabilitiesPromise = sdk.client.experimental.capabilities
|
||||
.get({ workspace }, { throwOnError: true })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
const consoleStatePromise = sdk.client.experimental.console
|
||||
.get({ workspace }, { throwOnError: true })
|
||||
.then((x) => x.data)
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
await Promise.all([
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
capabilitiesPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
])
|
||||
.then(async () => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
const capabilitiesResponse = capabilitiesPromise
|
||||
const consoleStateResponse = consoleStatePromise
|
||||
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
|
||||
const configResponse = configPromise.then((x) => x.data!)
|
||||
const sessionListResponse = args.continue ? sessionListPromise : undefined
|
||||
|
||||
return Promise.all([
|
||||
providersResponse,
|
||||
providerListResponse,
|
||||
capabilitiesResponse,
|
||||
consoleStateResponse,
|
||||
agentsResponse,
|
||||
configResponse,
|
||||
...(sessionListResponse ? [sessionListResponse] : []),
|
||||
]).then((responses) => {
|
||||
const providers = responses[0]
|
||||
const providerList = responses[1]
|
||||
const capabilities = responses[2]
|
||||
const consoleState = responses[3]
|
||||
const agents = responses[4]
|
||||
const config = responses[5]
|
||||
const sessions = responses[6]
|
||||
|
||||
batch(() => {
|
||||
setStore("provider", reconcile(providers.providers))
|
||||
setStore("provider_default", reconcile(providers.default))
|
||||
setStore("provider_next", reconcile(providerList))
|
||||
setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
|
||||
setStore("console_state", reconcile(consoleState))
|
||||
setStore("agent", reconcile(agents))
|
||||
setStore("config", reconcile(config))
|
||||
if (sessions !== undefined) setStore("session", reconcile(sessions))
|
||||
})
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
// non-blocking
|
||||
void Promise.all([
|
||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
|
||||
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
|
||||
sdk.client.experimental.resource
|
||||
.list({ workspace })
|
||||
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
|
||||
sdk.client.session.status({ workspace }).then((x) => {
|
||||
setStore("session_status", reconcile(x.data ?? {}))
|
||||
}),
|
||||
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||
project.workspace.sync(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error("tui bootstrap failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
name: e instanceof Error ? e.name : undefined,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
if (fatal) {
|
||||
exit(e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void bootstrap()
|
||||
})
|
||||
|
||||
const result = {
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
get status() {
|
||||
return store.status
|
||||
},
|
||||
get ready() {
|
||||
if (startup.skipInitialLoading) return true
|
||||
return store.status !== "loading"
|
||||
return true
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
},
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
get(_sessionID: string) {
|
||||
return undefined as Session | undefined
|
||||
},
|
||||
query() {
|
||||
return sessionListQuery()
|
||||
return {} as { scope?: "project"; path?: string }
|
||||
},
|
||||
async refresh() {
|
||||
const list = await listSessions()
|
||||
setStore("session", reconcile(list))
|
||||
},
|
||||
status(sessionID: string) {
|
||||
const session = result.session.get(sessionID)
|
||||
if (!session) return "idle"
|
||||
if (session.time.compacting) return "compacting"
|
||||
const messages = store.message[sessionID] ?? []
|
||||
const last = messages.at(-1)
|
||||
if (!last) return "idle"
|
||||
if (last.role === "user") return "working"
|
||||
return last.time.completed ? "idle" : "working"
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
if (fullSyncedSessions.has(sessionID)) return
|
||||
const syncing = syncingSessions.get(sessionID)
|
||||
if (syncing) return syncing
|
||||
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
|
||||
hydratingSessions.set(sessionID, tracker)
|
||||
const task = (async () => {
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
||||
sdk.client.session.todo({ sessionID }),
|
||||
sdk.client.session.diff({ sessionID }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
const currentMessages = draft.message[sessionID] ?? []
|
||||
const infos = (messages.data ?? []).flatMap((message) => {
|
||||
if (!tracker.messages.has(message.info.id)) return [message.info]
|
||||
const current = currentMessages.find((item) => item.id === message.info.id)
|
||||
return current ? [current] : []
|
||||
})
|
||||
infos.push(
|
||||
...currentMessages.filter(
|
||||
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
|
||||
),
|
||||
)
|
||||
const removed = infos.slice(0, -100)
|
||||
const visible = infos.slice(-100)
|
||||
const visibleIDs = new Set(visible.map((message) => message.id))
|
||||
for (const message of messages.data ?? []) {
|
||||
if (!visibleIDs.has(message.info.id)) {
|
||||
delete draft.part[message.info.id]
|
||||
continue
|
||||
}
|
||||
const currentParts = draft.part[message.info.id] ?? []
|
||||
const parts = message.parts.flatMap((part) => {
|
||||
const current = currentParts.find((item) => item.id === part.id)
|
||||
if (tracker.parts.has(part.id)) return current ? [current] : []
|
||||
if (
|
||||
current &&
|
||||
(part.type === "text" || part.type === "reasoning") &&
|
||||
(current.type === "text" || current.type === "reasoning") &&
|
||||
part.text.length === 0 &&
|
||||
current.text.length > 0
|
||||
) {
|
||||
return [current]
|
||||
}
|
||||
return [part]
|
||||
})
|
||||
parts.push(
|
||||
...currentParts.filter(
|
||||
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
|
||||
),
|
||||
)
|
||||
draft.part[message.info.id] = parts
|
||||
}
|
||||
for (const message of removed) delete draft.part[message.id]
|
||||
draft.message[sessionID] = visible
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
)
|
||||
fullSyncedSessions.add(sessionID)
|
||||
})().finally(() => {
|
||||
syncingSessions.delete(sessionID)
|
||||
hydratingSessions.delete(sessionID)
|
||||
})
|
||||
syncingSessions.set(sessionID, task)
|
||||
return task
|
||||
async refresh() {},
|
||||
status(_sessionID: string) {
|
||||
return "idle" as const
|
||||
},
|
||||
async sync(_sessionID: string) {},
|
||||
},
|
||||
bootstrap,
|
||||
async bootstrap(_input: { fatal?: boolean } = {}) {},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import type { PluginRuntime } from "../plugin/runtime"
|
||||
import HomeFooter from "./home/footer"
|
||||
import HomeTips from "./home/tips"
|
||||
import SidebarContext from "./sidebar/context"
|
||||
@@ -11,6 +12,7 @@ import DiffViewer from "./system/diff-viewer"
|
||||
import Notifications from "./system/notifications"
|
||||
import PluginManager from "./system/plugins"
|
||||
import WhichKey from "./system/which-key"
|
||||
import Scrap from "./system/scrap"
|
||||
|
||||
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
id: string
|
||||
@@ -18,7 +20,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] {
|
||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
return [
|
||||
HomeFooter,
|
||||
HomeTips,
|
||||
@@ -31,6 +33,45 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
|
||||
Notifications,
|
||||
PluginManager,
|
||||
WhichKey,
|
||||
Scrap,
|
||||
DiffViewer,
|
||||
]
|
||||
}
|
||||
|
||||
export async function loadBuiltinPlugins(
|
||||
api: TuiPluginApi,
|
||||
runtime: PluginRuntime,
|
||||
) {
|
||||
const slots = runtime.setupSlots(api)
|
||||
const dispose: Array<() => void> = []
|
||||
|
||||
for (const plugin of createBuiltinPlugins()) {
|
||||
if (plugin.enabled === false) continue
|
||||
const scoped = Object.assign(Object.create(api), {
|
||||
slots: {
|
||||
register(input: Parameters<typeof slots.register>[0]) {
|
||||
dispose.push(slots.register({ ...input, id: plugin.id }))
|
||||
return plugin.id
|
||||
},
|
||||
},
|
||||
}) as TuiPluginApi
|
||||
const now = Date.now()
|
||||
await plugin.tui(scoped, undefined, {
|
||||
id: plugin.id,
|
||||
source: "internal",
|
||||
spec: plugin.id,
|
||||
target: plugin.id,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: plugin.id,
|
||||
state: "first",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of dispose.reverse()) fn()
|
||||
slots.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const id = "internal:notifications"
|
||||
|
||||
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
|
||||
type SessionError = Extract<V2Event, { type: "session.error" }>["data"]["error"]
|
||||
|
||||
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||
@@ -33,38 +33,35 @@ const tui: TuiPlugin = async (api) => {
|
||||
const permissions = new Set<string>()
|
||||
|
||||
api.event.on("question.asked", (event) => {
|
||||
if (questions.has(event.properties.id)) return
|
||||
questions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Question needs input", "question")
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Question needs input", "question")
|
||||
})
|
||||
|
||||
api.event.on("question.replied", (event) => {
|
||||
questions.delete(event.properties.requestID)
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("question.rejected", (event) => {
|
||||
questions.delete(event.properties.requestID)
|
||||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.properties.id)) return
|
||||
permissions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Permission needs input", "permission")
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
||||
})
|
||||
|
||||
api.event.on("permission.replied", (event) => {
|
||||
permissions.delete(event.properties.requestID)
|
||||
permissions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("session.status", (event) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
|
||||
active.add(sessionID)
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const started = (sessionID: string) => {
|
||||
active.add(sessionID)
|
||||
errored.delete(sessionID)
|
||||
}
|
||||
|
||||
if (event.properties.status.type !== "idle") return
|
||||
const ended = (sessionID: string) => {
|
||||
if (!active.has(sessionID)) return
|
||||
active.delete(sessionID)
|
||||
|
||||
@@ -75,14 +72,32 @@ const tui: TuiPlugin = async (api) => {
|
||||
|
||||
const session = api.state.session.get(sessionID)
|
||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
api.event.on("session.next.prompted", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.step.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.retried", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.next.step.ended", (event) => {
|
||||
if (event.data.finish === "tool-calls") return
|
||||
ended(event.data.sessionID)
|
||||
})
|
||||
api.event.on("session.next.step.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, "Session error", "error")
|
||||
ended(sessionID)
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
|
||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useBindings } from "../../keymap"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const id = "internal:scrap"
|
||||
const route = "scrap"
|
||||
|
||||
function Scrap(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Back home",
|
||||
group: "Scrap",
|
||||
cmd() {
|
||||
props.api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme.textMuted}>esc home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.route.register([{ name: route, render: () => <Scrap api={api} /> }])
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: "app.scrap",
|
||||
title: "Open scrap screen",
|
||||
category: "Debug",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
api.route.navigate(route)
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = { id, tui }
|
||||
|
||||
export default plugin
|
||||
@@ -4,6 +4,7 @@ import type { useEvent } from "../context/event"
|
||||
import type { useRoute } from "../context/route"
|
||||
import type { useSDK } from "../context/sdk"
|
||||
import type { useSync } from "../context/sync"
|
||||
import type { useData } from "../context/data"
|
||||
import type { useTheme } from "../context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
|
||||
import type { useOpencodeKeymap } from "../keymap"
|
||||
@@ -31,6 +32,7 @@ type Input = {
|
||||
event: ReturnType<typeof useEvent>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
data: ReturnType<typeof useData>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
renderer: TuiPluginApi["renderer"]
|
||||
@@ -95,7 +97,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return sync.ready
|
||||
@@ -135,7 +137,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return sync.data.message[sessionID] ?? []
|
||||
},
|
||||
status(sessionID) {
|
||||
return sync.data.session_status[sessionID]
|
||||
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
|
||||
},
|
||||
permission(sessionID) {
|
||||
return sync.data.permission[sessionID] ?? []
|
||||
@@ -297,7 +299,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
|
||||
return input.kv.ready
|
||||
},
|
||||
},
|
||||
state: stateApi(input.sync),
|
||||
state: stateApi(input.sync, input.data),
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useData } from "../../context/data"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
import { stripPromptPartIDs as strip } from "../../prompt/part"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
sessionID: string
|
||||
setPrompt?: (prompt: PromptInfo) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
|
||||
const route = useRoute()
|
||||
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
|
||||
const data = useData()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
const message = createMemo(() =>
|
||||
data.session.message.get(props.sessionID, props.messageID),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
@@ -26,30 +23,12 @@ export function DialogMessage(props: {
|
||||
title: "Revert",
|
||||
value: "session.revert",
|
||||
description: "undo messages and file changes",
|
||||
onSelect: (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
|
||||
void sdk.client.session.revert({
|
||||
onSelect: async (dialog) => {
|
||||
const result = await sdk.client.v2.session.revert.stage({
|
||||
sessionID: props.sessionID,
|
||||
messageID: msg.id,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
|
||||
if (props.setPrompt) {
|
||||
const parts = sync.data.part[msg.id]
|
||||
const promptInfo = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(strip(part))
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
props.setPrompt(promptInfo)
|
||||
}
|
||||
|
||||
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -58,17 +37,19 @@ export function DialogMessage(props: {
|
||||
value: "message.copy",
|
||||
description: "message text to clipboard",
|
||||
onSelect: async (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
|
||||
const parts = sync.data.part[msg.id]
|
||||
const text = parts.reduce((agg, part) => {
|
||||
if (part.type === "text" && !part.synthetic) {
|
||||
agg += part.text
|
||||
}
|
||||
return agg
|
||||
}, "")
|
||||
|
||||
const value = message()
|
||||
if (!value) return
|
||||
const text =
|
||||
value.type === "user"
|
||||
? value.text
|
||||
: value.type === "assistant"
|
||||
? value.content
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("\n")
|
||||
: "text" in value
|
||||
? value.text
|
||||
: ""
|
||||
await clipboard.write?.(text)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -77,29 +58,8 @@ export function DialogMessage(props: {
|
||||
title: "Fork",
|
||||
value: "session.fork",
|
||||
description: "create a new session",
|
||||
onSelect: async (dialog) => {
|
||||
const result = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
const msg = message()
|
||||
const prompt = msg
|
||||
? sync.data.part[msg.id].reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
: undefined
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
prompt,
|
||||
})
|
||||
onSelect: (dialog) => {
|
||||
toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionV2Request } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useData } from "../../context/data"
|
||||
import { filetype } from "../../util/filetype"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
@@ -19,7 +18,7 @@ import { usePathFormatter } from "../../context/path-format"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function EditBody(props: { request: PermissionRequest }) {
|
||||
function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
||||
const themeState = useTheme()
|
||||
const theme = themeState.theme
|
||||
const syntax = themeState.syntax
|
||||
@@ -27,8 +26,7 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const filepath = createMemo(() => {
|
||||
const value = props.request.metadata?.filepath
|
||||
return typeof value === "string" ? value : ""
|
||||
return props.request.resources[0] ?? ""
|
||||
})
|
||||
const diff = createMemo(() => {
|
||||
const value = props.request.metadata?.diff
|
||||
@@ -79,9 +77,36 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={!diff()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>No diff provided</text>
|
||||
</box>
|
||||
<Show
|
||||
when={props.patch}
|
||||
fallback={
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>No diff provided</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(patch) => (
|
||||
<scrollbox
|
||||
height="100%"
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background,
|
||||
foregroundColor: theme.borderActive,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<code
|
||||
filetype="diff"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={patch()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</scrollbox>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
@@ -108,26 +133,22 @@ function TextBody(props: { title: string; description?: string; icon?: string })
|
||||
)
|
||||
}
|
||||
|
||||
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
|
||||
export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const [store, setStore] = createStore({
|
||||
stage: "permission" as PermissionStage,
|
||||
})
|
||||
const pathFormatter = usePathFormatter()
|
||||
|
||||
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
|
||||
const session = createMemo(() => data.session.get(props.request.sessionID))
|
||||
|
||||
const input = createMemo(() => {
|
||||
const tool = props.request.tool
|
||||
const tool = props.request.source
|
||||
if (!tool) return {}
|
||||
const parts = sync.data.part[tool.messageID] ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
|
||||
return part.state.input ?? {}
|
||||
}
|
||||
}
|
||||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
||||
if (message?.type !== "assistant") return {}
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
||||
if (part?.type === "tool" && part.state.status !== "pending") return part.state.input
|
||||
return {}
|
||||
})
|
||||
|
||||
@@ -140,14 +161,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
title="Always allow"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
|
||||
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
|
||||
<Match when={props.request.save?.length === 1 && props.request.save[0] === "*"}>
|
||||
<TextBody title={"This will allow " + props.request.action + " until OpenCode is restarted."} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
|
||||
<box>
|
||||
<For each={props.request.always}>
|
||||
<For each={props.request.save ?? []}>
|
||||
{(pattern) => (
|
||||
<text fg={theme.text}>
|
||||
{"- "}
|
||||
@@ -165,11 +186,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
onSelect={(option) => {
|
||||
setStore("stage", "permission")
|
||||
if (option === "cancel") return
|
||||
void sdk.client.permission.reply({
|
||||
void sdk.client.v2.session.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "always",
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
@@ -177,12 +197,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
<Match when={store.stage === "reject"}>
|
||||
<RejectPrompt
|
||||
onConfirm={(message) => {
|
||||
void sdk.client.permission.reply({
|
||||
void sdk.client.v2.session.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
message: message || undefined,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
@@ -193,21 +212,21 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
<Match when={store.stage === "permission"}>
|
||||
{(() => {
|
||||
const info = () => {
|
||||
const permission = props.request.permission
|
||||
const permission = props.request.action
|
||||
const data = input()
|
||||
|
||||
if (permission === "edit") {
|
||||
const raw = props.request.metadata?.filepath
|
||||
const filepath = typeof raw === "string" ? raw : ""
|
||||
const filepath = props.request.resources[0] ?? ""
|
||||
const patch = typeof data.patchText === "string" ? data.patchText : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||
body: <EditBody request={props.request} />,
|
||||
body: <EditBody request={props.request} patch={patch} />,
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "read") {
|
||||
const raw = data.filePath
|
||||
const raw = data.path
|
||||
const filePath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
@@ -271,8 +290,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
if (permission === "bash") {
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Shell command",
|
||||
body: (
|
||||
<Show when={command}>
|
||||
<box paddingLeft={1}>
|
||||
@@ -333,13 +350,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.patterns?.[0]
|
||||
const pattern = props.request.resources[0]
|
||||
const derived =
|
||||
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = pathFormatter.format(raw)
|
||||
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||
const patterns = props.request.resources.filter((p): p is string => typeof p === "string")
|
||||
|
||||
return {
|
||||
icon: "←",
|
||||
@@ -388,12 +405,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>Permission required</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted} flexShrink={0}>
|
||||
{current.icon}
|
||||
</text>
|
||||
<text fg={theme.text}>{current.title}</text>
|
||||
</box>
|
||||
<Show when={current.title}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted} flexShrink={0}>
|
||||
{current.icon}
|
||||
</text>
|
||||
<text fg={theme.text}>{current.title}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -402,7 +421,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
title="Permission required"
|
||||
header={header()}
|
||||
body={current.body}
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
options={
|
||||
props.request.save?.length
|
||||
? { once: "Allow once", always: "Allow always", reject: "Reject" }
|
||||
: { once: "Allow once", reject: "Reject" }
|
||||
}
|
||||
escapeKey="reject"
|
||||
fullscreen
|
||||
onSelect={(option) => {
|
||||
@@ -415,19 +438,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
setStore("stage", "reject")
|
||||
return
|
||||
}
|
||||
void sdk.client.permission.reply({
|
||||
void sdk.client.v2.session.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
return
|
||||
}
|
||||
void sdk.client.permission.reply({
|
||||
void sdk.client.v2.session.permission.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiConfig } from "../../config"
|
||||
@@ -11,7 +11,7 @@ import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
|
||||
const QUESTION_MODE = "question"
|
||||
|
||||
export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) {
|
||||
export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
@@ -24,7 +24,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as QuestionAnswer[],
|
||||
answers: [] as QuestionV2Answer[],
|
||||
custom: [] as string[],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
@@ -47,17 +47,17 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
|
||||
|
||||
function submit() {
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||
void sdk.client.question.reply({
|
||||
void sdk.client.v2.session.question.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
answers,
|
||||
questionV2Reply: { answers },
|
||||
})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
void sdk.client.question.reject({
|
||||
void sdk.client.v2.session.question.reject({
|
||||
sessionID: props.request.sessionID,
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
if (single()) {
|
||||
void sdk.client.question.reply({
|
||||
void sdk.client.v2.session.question.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
answers: [[answer]],
|
||||
questionV2Reply: { answers: [[answer]] },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type SessionRow =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| {
|
||||
type: "group"
|
||||
kind: "exploration"
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const data = useData()
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const boundary = revertBoundary()
|
||||
return reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = new Set(
|
||||
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
|
||||
request.source?.type === "tool" ? [request.source.callID] : [],
|
||||
),
|
||||
)
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
draft.forEach((row) => {
|
||||
if (row.type !== "group") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(sessionID, (id) => {
|
||||
setRows(reconcile(reduce()))
|
||||
void data.session.message.refresh(id).then(
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
// Re-reduce when the revert boundary changes (stage/clear/commit).
|
||||
createEffect(
|
||||
on(revertBoundary, () => {
|
||||
setRows(reconcile(reduce()))
|
||||
}),
|
||||
)
|
||||
|
||||
const appendMessage = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "message", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
const appendPart = (ref: PartRef, name?: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (hasPart(draft, ref)) return
|
||||
if (name && exploration(name)) {
|
||||
const previous = draft.at(-1)
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(draft)
|
||||
draft.push({
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
refs: [ref],
|
||||
pending: [],
|
||||
completed: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "part", ref })
|
||||
}),
|
||||
)
|
||||
|
||||
const appendFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "assistant-footer", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
const message = (event: { data: { sessionID: string; messageID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.next.prompted", message),
|
||||
data.on("session.next.context.updated", message),
|
||||
data.on("session.next.synthetic", message),
|
||||
data.on("session.next.shell.started", message),
|
||||
data.on("session.next.agent.switched", message),
|
||||
data.on("session.next.model.switched", message),
|
||||
data.on("session.next.compaction.ended", message),
|
||||
data.on("session.next.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
}),
|
||||
data.on("session.next.text.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
}),
|
||||
data.on("session.next.reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
}),
|
||||
data.on("session.next.reasoning.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
}),
|
||||
data.on("session.next.tool.input.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
||||
}),
|
||||
data.on("session.next.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.next.step.failed", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
]
|
||||
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
message.content.forEach((part) => {
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
||||
if (part.type === "tool") {
|
||||
if (exploration(part.name)) {
|
||||
const previous = rows.at(-1)
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
|
||||
return
|
||||
}
|
||||
}
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "part", ref })
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[]) {
|
||||
const previous = rows.at(-1)
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
return [...row.refs, ...row.pending].some(
|
||||
(item) => item.messageID === ref.messageID && item.partID === ref.partID,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTuiConfig } from "../../config"
|
||||
@@ -12,12 +12,12 @@ import { WorkspaceLabel } from "../../component/workspace-label"
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const workspace = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
const workspaceID = session()?.location.workspaceID
|
||||
if (!workspaceID) return
|
||||
return project.workspace.get(workspaceID)
|
||||
}
|
||||
@@ -51,7 +51,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
mode="single_winner"
|
||||
session_id={props.sessionID}
|
||||
title={session()!.title}
|
||||
share_url={session()!.share?.url}
|
||||
>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
@@ -60,11 +59,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<Show when={InstallationChannel !== "latest"}>
|
||||
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<Show
|
||||
when={workspace()}
|
||||
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
|
||||
fallback={
|
||||
<WorkspaceLabel type="unknown" name={session()!.location.workspaceID!} status="error" icon />
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<WorkspaceLabel
|
||||
@@ -77,9 +78,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</Show>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={session()!.share?.url}>
|
||||
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</pluginRuntime.Slot>
|
||||
<pluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
|
||||
|
||||
@@ -1,47 +1,40 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useRouteData } from "../../context/route"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useData } from "../../context/data"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||
|
||||
export function SubagentFooter() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const session = createMemo(() => sync.session.get(route.sessionID))
|
||||
const data = useData()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
|
||||
const subagentInfo = createMemo(() => {
|
||||
const s = session()
|
||||
if (!s) return { label: "Subagent", index: 0, total: 0 }
|
||||
if (!s) return "Subagent"
|
||||
const agentMatch = s.title.match(/@(\w+) subagent/)
|
||||
const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
|
||||
if (!s.parentID) return { label, index: 0, total: 0 }
|
||||
|
||||
const siblings = sync.data.session
|
||||
.filter((x) => x.parentID === s.parentID)
|
||||
.toSorted((a, b) => a.time.created - b.time.created)
|
||||
const index = siblings.findIndex((x) => x.id === s.id)
|
||||
|
||||
return { label, index: index + 1, total: siblings.length }
|
||||
return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
const msg = messages()
|
||||
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||
if (!last) return
|
||||
|
||||
const current = session()
|
||||
if (!current) return
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
current.tokens.input +
|
||||
current.tokens.output +
|
||||
current.tokens.reasoning +
|
||||
current.tokens.cache.read +
|
||||
current.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||
const model = data.location
|
||||
.model.list(current.location)
|
||||
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = session()?.cost ?? 0
|
||||
const cost = current.cost
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
@@ -78,13 +71,8 @@ export function SubagentFooter() {
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>{subagentInfo().label}</b>
|
||||
<b>{subagentInfo()}</b>
|
||||
</text>
|
||||
<Show when={subagentInfo().total > 0}>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
({subagentInfo().index} of {subagentInfo().total})
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
@@ -95,10 +83,10 @@ export function SubagentFooter() {
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatchCommand("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user