mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 13:16:17 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c57e112117 | ||
|
|
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:
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -316,6 +316,7 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -934,6 +935,7 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# V2 CLI and TUI development guide
|
||||
|
||||
## Migration context
|
||||
|
||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
|
||||
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
|
||||
|
||||
```bash
|
||||
# From packages/cli: local V2 TUI
|
||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
|
||||
|
||||
# Released legacy TUI behavior reference
|
||||
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
|
||||
|
||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
|
||||
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
|
||||
```
|
||||
|
||||
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
|
||||
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
|
||||
|
||||
## Interactive debugging
|
||||
|
||||
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
|
||||
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
|
||||
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
|
||||
- Use a dedicated session name and do not reuse or kill an unrelated session.
|
||||
|
||||
```bash
|
||||
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
|
||||
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
|
||||
termctrl show opencode-v2-dev
|
||||
```
|
||||
|
||||
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
|
||||
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
|
||||
|
||||
```bash
|
||||
termctrl send opencode-v2-dev 'text:example prompt' enter
|
||||
termctrl send opencode-v2-dev ctrl-c
|
||||
```
|
||||
|
||||
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
|
||||
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
|
||||
|
||||
```bash
|
||||
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
|
||||
```
|
||||
|
||||
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
|
||||
|
||||
```bash
|
||||
termctrl resize opencode-v2-dev --cols 100 --rows 30
|
||||
termctrl show opencode-v2-dev
|
||||
```
|
||||
|
||||
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
|
||||
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
|
||||
- Always clean up the Terminal Control session when the check is complete:
|
||||
|
||||
```bash
|
||||
termctrl stop opencode-v2-dev
|
||||
```
|
||||
|
||||
## Debugger
|
||||
|
||||
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
|
||||
|
||||
```bash
|
||||
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
|
||||
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
|
||||
```
|
||||
|
||||
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
|
||||
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
|
||||
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
|
||||
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
|
||||
@@ -31,11 +31,11 @@ function run(target) {
|
||||
|
||||
const envPath = process.env.OPENCODE_BIN_PATH
|
||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||
const cached = path.join(scriptDir, ".lildax")
|
||||
const cached = path.join(scriptDir, ".opencode2")
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "lildax.exe" : "lildax"
|
||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
@@ -121,7 +121,7 @@ function findBinary(startDir) {
|
||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
|
||||
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
|
||||
names.map((name) => `"${name}"`).join(" or ") +
|
||||
" package",
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs"
|
||||
"opencode2": "./bin/opencode2.cjs"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
|
||||
@@ -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,24 @@
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
if (directory !== undefined) process.chdir(directory)
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(transport)
|
||||
yield* runTui(
|
||||
transport,
|
||||
input.standalone
|
||||
? undefined
|
||||
: async () => {
|
||||
await Effect.runPromise(daemon.stop())
|
||||
return Effect.runPromise(daemon.transport())
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { createRoutes } from "@opencode-ai/server/routes"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
@@ -16,15 +18,41 @@ export default Runtime.handler(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
const password = input.stdio ? standalonePassword : yield* daemon.password()
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const address = yield* listen(input.hostname, input.port, password)
|
||||
yield* Effect.tryPromise(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: HttpServer.formatAddress(address),
|
||||
headers: ServerAuth.headers({ password }),
|
||||
}).v2.location.get(undefined, { throwOnError: true }),
|
||||
)
|
||||
if (input.register) yield* daemon.register(address)
|
||||
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
const url = HttpServer.formatAddress(address)
|
||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function waitForStdinClose() {
|
||||
return Effect.callback<void>((resume) => {
|
||||
const close = () => resume(Effect.void)
|
||||
process.stdin.once("end", close)
|
||||
process.stdin.once("close", close)
|
||||
process.stdin.resume()
|
||||
if (process.stdin.readableEnded || process.stdin.destroyed) close()
|
||||
return Effect.sync(() => {
|
||||
process.stdin.off("end", close)
|
||||
process.stdin.off("close", close)
|
||||
process.stdin.pause()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
@@ -35,11 +63,15 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
Layer.provide(Credential.defaultLayer),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
),
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
|
||||
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const url = yield* (yield* Daemon.Service).status()
|
||||
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
|
||||
process.stdout.write((url ? url : "stopped") + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Spec } from "./spec"
|
||||
import { Daemon } from "../services/daemon"
|
||||
import { Scope } from "effect"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -10,11 +11,11 @@ export type Input<Value> =
|
||||
? Input
|
||||
: never
|
||||
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Layer, Logger, References } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||
|
||||
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
)
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -25,8 +34,11 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
})
|
||||
|
||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Daemon.defaultLayer),
|
||||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
+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
|
||||
}
|
||||
}
|
||||
@@ -510,6 +510,7 @@ export type SessionsContextOutput = {
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
@@ -1127,6 +1128,7 @@ export type SessionsMessageOutput = {
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { Reference } from "../reference"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { State } from "../state"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
@@ -102,20 +103,22 @@ export const locationLayer = Layer.effectDiscard(
|
||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(VariantPlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(VariantPlugin.Plugin)
|
||||
}),
|
||||
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Duration, Effect, Schema, Stream } from "effect"
|
||||
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
@@ -79,6 +79,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let connected = false
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
|
||||
@@ -105,7 +106,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||
})
|
||||
|
||||
yield* load()
|
||||
connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
@@ -176,11 +177,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
readonly combineOutput?: boolean
|
||||
readonly maxOutputBytes?: number
|
||||
readonly maxErrorBytes?: number
|
||||
readonly signal?: AbortSignal
|
||||
@@ -37,8 +38,10 @@ export interface RunStreamOptions {
|
||||
export interface RunResult {
|
||||
readonly command: string
|
||||
readonly exitCode: number
|
||||
readonly output?: Buffer
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly outputTruncated?: boolean
|
||||
readonly stdoutTruncated: boolean
|
||||
readonly stderrTruncated: boolean
|
||||
}
|
||||
@@ -143,6 +146,22 @@ export const layer = Layer.effect(
|
||||
const collect = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
if (options?.combineOutput) {
|
||||
const [output, exitCode] = yield* Effect.all(
|
||||
[collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return {
|
||||
command: description,
|
||||
exitCode,
|
||||
output: output.buffer,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
outputTruncated: output.truncated,
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
} satisfies RunResult
|
||||
}
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, options?.maxOutputBytes),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export * as PublicEventManifest from "./public-event-manifest"
|
||||
|
||||
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
|
||||
export const Definitions = EventManifest.ServerDefinitions
|
||||
export const Latest = Event.latest(Definitions)
|
||||
|
||||
@@ -390,7 +390,13 @@ export const layer = Layer.unwrap(
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
|
||||
@@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
}
|
||||
})
|
||||
|
||||
@@ -333,7 +333,8 @@ export const layer = Layer.effect(
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -132,7 +132,7 @@ export const fromCatalogModel = (
|
||||
credential?: Credential.Value,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const resolved =
|
||||
credential?.metadata === undefined
|
||||
credential?.type !== "key" || credential.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.metadata)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
@@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
|
||||
target: Schema.String,
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
|
||||
export const Output = Schema.Struct({
|
||||
applied: Schema.Array(Applied),
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
export const toModelOutput = (output: Output) =>
|
||||
@@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
|
||||
].join("\n")
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly source: Uint8Array
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
@@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
|
||||
for (const { hunk, target } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({ ...hunk, target })
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after:
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ ...hunk, target })
|
||||
prepared.push({ ...hunk, target, before, after: "" })
|
||||
return
|
||||
}
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const update = Patch.derive(
|
||||
hunk.path,
|
||||
hunk.chunks,
|
||||
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
|
||||
)
|
||||
const update = Patch.derive(hunk.path, hunk.chunks, original)
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
source,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before,
|
||||
after: update.content,
|
||||
})
|
||||
}).pipe(Effect.mapError(() => fail(hunk.path)))
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
@@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
|
||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied }
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
},
|
||||
}),
|
||||
@@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const counts = diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
return {
|
||||
file: change.target.resource,
|
||||
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
|
||||
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
|
||||
...counts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,16 +30,15 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
command: Schema.String,
|
||||
cwd: Schema.String,
|
||||
exitCode: Schema.Number.pipe(Schema.optional),
|
||||
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
|
||||
output: Schema.String,
|
||||
const StructuredOutput = Schema.Struct({
|
||||
exit: Schema.Number.pipe(Schema.optional),
|
||||
truncated: Schema.Boolean,
|
||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
||||
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
...StructuredOutput.fields,
|
||||
output: Schema.String,
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
@@ -47,24 +46,12 @@ type Output = typeof Output.Type
|
||||
|
||||
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
|
||||
|
||||
const compactOutput = (stdout: string, stderr: string) => {
|
||||
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
|
||||
return output || "(no output)"
|
||||
}
|
||||
|
||||
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
|
||||
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
|
||||
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
|
||||
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const modelOutput = (output: Output) => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
|
||||
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
|
||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
const isTimeout = (error: AppProcess.AppProcessError) =>
|
||||
@@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard(
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
truncated: output.truncated,
|
||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||
}),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "text", text: output.output },
|
||||
{ type: "text", text: modelOutput(output) },
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard(
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const result = yield* appProcess
|
||||
.run(command, {
|
||||
combineOutput: true,
|
||||
timeout: Duration.millis(timeout),
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("AppProcessError", (error) =>
|
||||
@@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard(
|
||||
)
|
||||
if (!result) {
|
||||
return {
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timedOut: true,
|
||||
timeout: true,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
|
||||
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
|
||||
const output = result.output?.toString("utf8") || "(no output)"
|
||||
const notice = result.outputTruncated
|
||||
? "[output capture truncated at the in-memory safety limit]"
|
||||
: undefined
|
||||
return {
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
exitCode: result.exitCode,
|
||||
output: notice ? `${compact}\n\n${notice}` : compact,
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
exit: result.exitCode,
|
||||
output: notice ? `${output}\n\n${notice}` : output,
|
||||
truncated: result.outputTruncated === true,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
@@ -30,10 +32,7 @@ export const Input = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
replacements: Schema.Number,
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
@@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
|
||||
|
||||
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
|
||||
[
|
||||
`Edited file successfully: ${output.resource}`,
|
||||
`Edited file successfully: ${output.files[0]?.file}`,
|
||||
`Replacements: ${output.replacements}`,
|
||||
"```diff",
|
||||
...previewLines(oldString, "-"),
|
||||
@@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
|
||||
input.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const counts = diffLines(source.text, replaced).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
@@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Output
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -37,10 +37,19 @@ export type Content =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||
|
||||
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
|
||||
type Config<
|
||||
Input extends SchemaType<any>,
|
||||
Output extends SchemaType<any>,
|
||||
Structured extends SchemaType<any> = Output,
|
||||
> = {
|
||||
readonly description: string
|
||||
readonly input: Input
|
||||
readonly output: Output
|
||||
readonly structured?: Structured
|
||||
readonly toStructuredOutput?: (input: {
|
||||
readonly input: Schema.Schema.Type<Input>
|
||||
readonly output: Output["Encoded"]
|
||||
}) => Schema.Schema.Type<Structured>
|
||||
readonly execute: (
|
||||
input: Schema.Schema.Type<Input>,
|
||||
context: Context,
|
||||
@@ -59,10 +68,12 @@ type Runtime = {
|
||||
|
||||
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||
|
||||
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
config: Config<Input, Output>,
|
||||
): Definition<Input, Output> {
|
||||
const tool = Object.freeze({}) as Definition<Input, Output>
|
||||
export function make<
|
||||
Input extends SchemaType<any>,
|
||||
Output extends SchemaType<any>,
|
||||
Structured extends SchemaType<any> = Output,
|
||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
|
||||
const tool = Object.freeze({}) as Definition<Input, Structured>
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
runtimes.set(tool, {
|
||||
definition: (name) => {
|
||||
@@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
|
||||
name,
|
||||
description: config.description,
|
||||
inputSchema: toJsonSchema(config.input),
|
||||
outputSchema: toJsonSchema(config.output),
|
||||
outputSchema: toJsonSchema(config.structured ?? config.output),
|
||||
})
|
||||
definitions.set(name, definition)
|
||||
return definition
|
||||
@@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
|
||||
config.execute(input, context).pipe(
|
||||
Effect.flatMap((output) =>
|
||||
Schema.encodeEffect(config.output)(output).pipe(
|
||||
Effect.flatMap((output) => {
|
||||
if (!config.structured || !config.toStructuredOutput)
|
||||
return Effect.succeed({ output, structured: output })
|
||||
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
|
||||
Effect.map((structured) => ({ output, structured })),
|
||||
)
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -92,8 +110,8 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map((output) => ({
|
||||
structured: output,
|
||||
Effect.map(({ output, structured }) => ({
|
||||
structured,
|
||||
content:
|
||||
config.toModelOutput?.({ input, output }).map((part) =>
|
||||
part.type === "text"
|
||||
|
||||
@@ -42,6 +42,15 @@ const openai: Lowerer = {
|
||||
},
|
||||
request(options) {
|
||||
const result = snake(options)
|
||||
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
|
||||
result.reasoning = {
|
||||
...(isRecord(result.reasoning) ? result.reasoning : {}),
|
||||
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
|
||||
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
|
||||
}
|
||||
delete result.reasoning_effort
|
||||
delete result.reasoning_summary
|
||||
}
|
||||
if (options.textVerbosity !== undefined) {
|
||||
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
|
||||
delete result.text_verbosity
|
||||
|
||||
@@ -601,9 +601,9 @@ describe("Config", () => {
|
||||
models: {
|
||||
model: {
|
||||
request: {
|
||||
body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
|
||||
body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
|
||||
},
|
||||
variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
|
||||
variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => {
|
||||
expect(
|
||||
lowerer.request({
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
reasoning: { encryptedContent: true },
|
||||
textVerbosity: "low",
|
||||
text: { outputFormat: "plain" },
|
||||
nestedValue: { camelCase: true },
|
||||
}),
|
||||
).toEqual({
|
||||
reasoning_effort: "high",
|
||||
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
|
||||
text: { output_format: "plain", verbosity: "low" },
|
||||
nested_value: { camel_case: true },
|
||||
})
|
||||
@@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
|
||||
body: { trace: true },
|
||||
settings: { resourceName: "resource" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
|
||||
reasoning_effort: "high",
|
||||
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
text: { verbosity: "low" },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,20 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -67,11 +81,14 @@ describe("OpencodePlugin", () => {
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const authorization: Array<string | null> = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
return {
|
||||
authorization,
|
||||
release: gate.resolve,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
fetch: async (request) => {
|
||||
await gate.promise
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
const origin = new URL(request.url).origin
|
||||
return Response.json({
|
||||
@@ -110,7 +127,7 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ authorization, server }) =>
|
||||
({ authorization, release, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -128,8 +145,15 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
expect(authorization).toEqual([])
|
||||
release()
|
||||
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
|
||||
const provider = required(
|
||||
yield* eventually(
|
||||
catalog.provider.get(ProviderV2.ID.make("remote")),
|
||||
(item) => item?.integrationID === Integration.ID.make("opencode"),
|
||||
),
|
||||
)
|
||||
expect(provider).toMatchObject({
|
||||
name: "Remote",
|
||||
integrationID: "opencode",
|
||||
|
||||
@@ -39,6 +39,22 @@ describe("AppProcess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"captures stdout and stderr in emission order",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const script = [
|
||||
'process.stdout.write("out 1\\n")',
|
||||
'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
|
||||
'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
|
||||
].join(";")
|
||||
const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
|
||||
expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
|
||||
expect(result.stdout.toString("utf8")).toBe("")
|
||||
expect(result.stderr.toString("utf8")).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"non-zero exit returns RunResult; caller can require success",
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists repeated switches as distinct durable Session events", () =>
|
||||
it.effect("ignores a model switch when the selected model is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
@@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toHaveLength(3)
|
||||
).toHaveLength(2)
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an omitted variant as the default variant", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
|
||||
const created = yield* session.create({ location, model })
|
||||
|
||||
yield* session.switchModel({
|
||||
sessionID: created.id,
|
||||
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
|
||||
})
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a model switch for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
@@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not project OAuth account metadata into the request body", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "secret",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { server: "https://console.example", orgID: "org_123" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
||||
@@ -2565,7 +2565,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates unexpected local tool defects operationally", () =>
|
||||
it.effect("returns unexpected local tool defects to the model and continues", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -2579,11 +2579,20 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-after-defect" }),
|
||||
LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }),
|
||||
LLMEvent.textEnd({ id: "text-after-defect" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
],
|
||||
]
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Call defect" },
|
||||
{
|
||||
@@ -2599,6 +2608,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => {
|
||||
{ type: "update", resource: "update.txt" },
|
||||
{ type: "delete", resource: "remove.txt" },
|
||||
],
|
||||
files: [
|
||||
{
|
||||
file: "nested/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
{
|
||||
file: "update.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
{
|
||||
file: "remove.txt",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-remove"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
|
||||
|
||||
@@ -31,8 +31,10 @@ let denyAction: string | undefined
|
||||
let result: AppProcess.RunResult = {
|
||||
command: "mock",
|
||||
exitCode: 0,
|
||||
output: Buffer.from("hello\n"),
|
||||
stdout: Buffer.from("hello\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
outputTruncated: false,
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
}
|
||||
@@ -83,8 +85,10 @@ const reset = () => {
|
||||
result = {
|
||||
command: "mock",
|
||||
exitCode: 0,
|
||||
output: Buffer.from("hello\n"),
|
||||
stdout: Buffer.from("hello\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
outputTruncated: false,
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
}
|
||||
@@ -135,24 +139,33 @@ describe("BashTool", () => {
|
||||
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
|
||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
|
||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
|
||||
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
|
||||
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
|
||||
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
|
||||
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
|
||||
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "hello\n" },
|
||||
{ type: "text", text: "Command exited with code 0." },
|
||||
],
|
||||
},
|
||||
output: {
|
||||
structured: {
|
||||
command: "pwd",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 0,
|
||||
output: "hello\n",
|
||||
exit: 0,
|
||||
truncated: false,
|
||||
},
|
||||
content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }],
|
||||
content: [
|
||||
{ type: "text", text: "hello\n" },
|
||||
{ type: "text", text: "Command exited with code 0." },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
|
||||
expect(runs[0]?.options).toMatchObject({
|
||||
combineOutput: true,
|
||||
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
|
||||
}),
|
||||
@@ -222,13 +235,17 @@ describe("BashTool", () => {
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
command: "printf core-bash",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 0,
|
||||
output: "core-bash",
|
||||
expect(settled.result).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "core-bash" },
|
||||
{ type: "text", text: "Command exited with code 0." },
|
||||
],
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
exit: 0,
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("output")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -303,11 +320,13 @@ describe("BashTool", () => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
warnings: [
|
||||
`Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
],
|
||||
truncated: false,
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("warnings")
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Warnings:"),
|
||||
})
|
||||
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -324,21 +343,19 @@ describe("BashTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
|
||||
result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
|
||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toMatchObject({
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("Command exited with code 7"),
|
||||
text: expect.stringContaining("Command exited with code 7"),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
command: "false",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 7,
|
||||
output: "HEAD full output TAIL",
|
||||
exit: 7,
|
||||
truncated: false,
|
||||
})
|
||||
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -352,14 +369,14 @@ describe("BashTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
result = { ...result, stdoutTruncated: true }
|
||||
result = { ...result, outputTruncated: true }
|
||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
|
||||
expect(settled.result).toMatchObject({
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: true })
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("stdout capture truncated"),
|
||||
text: expect.stringContaining("output capture truncated"),
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("resource")
|
||||
}),
|
||||
@@ -379,13 +396,12 @@ describe("BashTool", () => {
|
||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toMatchObject({
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("Command timed out"),
|
||||
text: expect.stringContaining("Command timed out"),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
command: "sleep 60",
|
||||
timedOut: true,
|
||||
timeout: true,
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -125,11 +125,16 @@ describe("EditTool", () => {
|
||||
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({
|
||||
operation: "write",
|
||||
target: yield* Effect.promise(() => fs.realpath(target)),
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
replacements: 1,
|
||||
files: [
|
||||
{
|
||||
file: "hello.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||
}).pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
|
||||
|
||||
@@ -3926,6 +3926,10 @@ export type SessionMessageAssistantReasoning = {
|
||||
id: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageToolStatePending = {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Api } from "../api"
|
||||
|
||||
function eventData(data: unknown): Sse.Event {
|
||||
const event = data as EventV2.Payload
|
||||
const definition = PublicEventManifest.Latest.get(event.type)
|
||||
const encoded = definition
|
||||
? {
|
||||
...event,
|
||||
data: Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
|
||||
}
|
||||
: event
|
||||
return {
|
||||
_tag: "Event",
|
||||
event: "message",
|
||||
id: undefined,
|
||||
data: JSON.stringify(data),
|
||||
data: JSON.stringify(encoded),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -969,7 +969,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
|
||||
<div data-component="context-tool-group-trigger">
|
||||
<span
|
||||
data-slot="context-tool-group-title"
|
||||
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
|
||||
class="min-w-0 flex items-center gap-2 text-14-medium"
|
||||
classList={{
|
||||
"text-text-strong": pending(),
|
||||
"text-text-weak": !pending(),
|
||||
}}
|
||||
>
|
||||
<span data-slot="context-tool-group-label" class="shrink-0">
|
||||
<ToolStatusTitle
|
||||
@@ -981,7 +985,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
|
||||
</span>
|
||||
<span
|
||||
data-slot="context-tool-group-summary"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal"
|
||||
classList={{
|
||||
"text-text-base": pending(),
|
||||
"text-text-weak": !pending(),
|
||||
}}
|
||||
>
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
|
||||
@@ -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,479 @@
|
||||
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"
|
||||
import open from "open"
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "o",
|
||||
desc: "Open authorization URL",
|
||||
group: "Dialog",
|
||||
cmd: () => open(props.attempt.url).catch(() => {}),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
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
|
||||
openURL
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
openURL?: 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>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={props.copy}>
|
||||
<text fg={theme.text}>
|
||||
c <span style={{ fg: theme.textMuted }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={props.openURL}>
|
||||
<text fg={theme.text}>
|
||||
o <span style={{ fg: theme.textMuted }}>open</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</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,54 @@
|
||||
import { createMemo, createResource, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { 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 { 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 +58,70 @@ 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: () => unavailable("Renaming"),
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
|
||||
@@ -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
|
||||
@@ -982,6 +982,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()
|
||||
@@ -991,10 +992,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,
|
||||
@@ -1004,8 +1004,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",
|
||||
@@ -1014,7 +1012,8 @@ export function Prompt(props: PromptProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
sessionID = res.data.id
|
||||
sessionID = res.data.data.id
|
||||
session = res.data.data
|
||||
}
|
||||
|
||||
const inputText = expandTrackedPastedText(
|
||||
@@ -1054,7 +1053,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,
|
||||
@@ -1078,39 +1077,72 @@ 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",
|
||||
})
|
||||
})
|
||||
}
|
||||
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({
|
||||
@@ -1284,7 +1316,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(() => {
|
||||
@@ -1315,10 +1347,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,
|
||||
@@ -1439,7 +1471,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}>
|
||||
@@ -1501,78 +1533,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 }}>
|
||||
@@ -1633,41 +1607,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,
|
||||
@@ -150,8 +170,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
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 +186,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 +196,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 +207,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 +220,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 +229,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 +246,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 +259,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 +269,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 +278,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 +301,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 +322,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 +349,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 +369,47 @@ 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.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 +419,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 +467,61 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsub = events.subscribe((event, metadata) => {
|
||||
handleEvent({
|
||||
...event,
|
||||
data: event.properties,
|
||||
location: { directory: metadata.directory, workspaceID: metadata.workspace },
|
||||
} as V2Event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
const result = {
|
||||
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 +572,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 +582,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 +595,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 +605,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 +615,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 +625,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 +635,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 +669,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,16 @@
|
||||
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"
|
||||
|
||||
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 message = createMemo(() =>
|
||||
data.session.message.get(props.sessionID, props.messageID),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
@@ -27,29 +21,7 @@ export function DialogMessage(props: {
|
||||
value: "session.revert",
|
||||
description: "undo messages and file changes",
|
||||
onSelect: (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
|
||||
void sdk.client.session.revert({
|
||||
sessionID: props.sessionID,
|
||||
messageID: msg.id,
|
||||
})
|
||||
|
||||
if (props.setPrompt) {
|
||||
const parts = sync.data.part[msg.id]
|
||||
const promptInfo = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(strip(part))
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
props.setPrompt(promptInfo)
|
||||
}
|
||||
|
||||
toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -58,17 +30,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 +51,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,198 @@
|
||||
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type SessionRow =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| {
|
||||
type: "group"
|
||||
kind: "exploration"
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const data = useData()
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
|
||||
createEffect(() => {
|
||||
const pending = new Set(
|
||||
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
|
||||
request.source?.type === "tool" ? [request.source.callID] : [],
|
||||
),
|
||||
)
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
draft.forEach((row) => {
|
||||
if (row.type !== "group") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(sessionID, (id) => {
|
||||
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
|
||||
void data.session.message.refresh(id).then(
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const appendMessage = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "message", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
const appendPart = (ref: PartRef, name?: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (hasPart(draft, ref)) return
|
||||
if (name && exploration(name)) {
|
||||
const previous = draft.at(-1)
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(draft)
|
||||
draft.push({
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
refs: [ref],
|
||||
pending: [],
|
||||
completed: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "part", ref })
|
||||
}),
|
||||
)
|
||||
|
||||
const appendFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "assistant-footer", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
const message = (event: { data: { sessionID: string; messageID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.next.prompted", message),
|
||||
data.on("session.next.context.updated", message),
|
||||
data.on("session.next.synthetic", message),
|
||||
data.on("session.next.shell.started", message),
|
||||
data.on("session.next.agent.switched", message),
|
||||
data.on("session.next.model.switched", message),
|
||||
data.on("session.next.compaction.ended", message),
|
||||
data.on("session.next.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
}),
|
||||
data.on("session.next.text.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
}),
|
||||
data.on("session.next.reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
}),
|
||||
data.on("session.next.reasoning.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
}),
|
||||
data.on("session.next.tool.input.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
||||
}),
|
||||
data.on("session.next.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.next.step.failed", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
]
|
||||
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
message.content.forEach((part) => {
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
||||
if (part.type === "tool") {
|
||||
if (exploration(part.name)) {
|
||||
const previous = rows.at(-1)
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
|
||||
return
|
||||
}
|
||||
}
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "part", ref })
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[]) {
|
||||
const previous = rows.at(-1)
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
return [...row.refs, ...row.pending].some(
|
||||
(item) => item.messageID === ref.messageID && item.partID === ref.partID,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
{props.description}
|
||||
{props.description?.()}
|
||||
<textarea
|
||||
height={3}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
|
||||
@@ -4,7 +4,9 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
||||
return { output, overflow: false }
|
||||
}
|
||||
|
||||
const preview = lines.slice(0, maxLines).join("\n")
|
||||
const visible = lines.slice(0, maxLines)
|
||||
if (lines.length > maxLines && visible.length > 0) visible[visible.length - 1] += "…"
|
||||
const preview = visible.join("\n")
|
||||
if (Array.from(preview).length > maxChars) {
|
||||
return {
|
||||
output:
|
||||
@@ -15,5 +17,5 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
||||
}
|
||||
}
|
||||
|
||||
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
|
||||
return { output: preview, overflow: true }
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { BaseRenderable, BoxRenderable } from "@opentui/core"
|
||||
|
||||
const previousByParent = new WeakMap<
|
||||
BaseRenderable,
|
||||
{ frameID: number; previous: WeakMap<BaseRenderable, BaseRenderable | undefined> }
|
||||
>()
|
||||
|
||||
export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) {
|
||||
// Run before Yoga layout so scroll geometry matches the rendered frame.
|
||||
el.onLifecyclePass = () => {
|
||||
const parent = el.parent
|
||||
if (!parent) return
|
||||
const cached = previousByParent.get(parent)
|
||||
const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId)
|
||||
const value = margin(previous.get(el))
|
||||
if (el.marginTop !== value) el.marginTop = value
|
||||
}
|
||||
}
|
||||
|
||||
function previousSiblings(parent: BaseRenderable, frameID: number) {
|
||||
const previous = new WeakMap<BaseRenderable, BaseRenderable | undefined>()
|
||||
parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1]))
|
||||
previousByParent.set(parent, { frameID, previous })
|
||||
return previous
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||
import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
@@ -17,8 +17,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
setTitle(title)
|
||||
}
|
||||
const listeners = new Set(process.listeners("SIGHUP"))
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
@@ -29,11 +29,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
client: createClient(calls.fetch),
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
args: {},
|
||||
pluginHost: {
|
||||
async start() {
|
||||
@@ -63,21 +60,27 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventSource()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/session")
|
||||
return json([
|
||||
{
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
slug: "dummy",
|
||||
projectID: "project",
|
||||
directory,
|
||||
version: "0.0.0-test",
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
])
|
||||
})
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
events,
|
||||
)
|
||||
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||
let stdout = ""
|
||||
let api: TuiPluginApi | undefined
|
||||
@@ -95,11 +98,8 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
client: createClient(calls.fetch),
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
args: { continue: true },
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
connectionSummary,
|
||||
connectMethods,
|
||||
credentialConnections,
|
||||
integrationOptions,
|
||||
} from "../../../../src/component/dialog-integration"
|
||||
|
||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||
methods: [],
|
||||
connections: [],
|
||||
...value,
|
||||
})
|
||||
|
||||
describe("integrationOptions", () => {
|
||||
test("keeps popular integrations first and sorts the rest alphabetically", () => {
|
||||
expect(
|
||||
integrationOptions([
|
||||
integration({ id: "mistral", name: "Mistral" }),
|
||||
integration({ id: "openai", name: "OpenAI" }),
|
||||
integration({ id: "custom-z", name: "Zebra" }),
|
||||
integration({ id: "anthropic", name: "Anthropic" }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("connectMethods", () => {
|
||||
test("offers key and OAuth methods but not environment discovery", () => {
|
||||
expect(
|
||||
connectMethods(
|
||||
integration({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
methods: [
|
||||
{ type: "env", names: ["EXAMPLE_KEY"] },
|
||||
{ type: "key", label: "API key" },
|
||||
{ type: "oauth", id: "account", label: "Account" },
|
||||
],
|
||||
}),
|
||||
).map((method) => method.type),
|
||||
).toEqual(["oauth", "key"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("credentialConnections", () => {
|
||||
test("returns removable credential connections only", () => {
|
||||
expect(
|
||||
credentialConnections(
|
||||
integration({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
connections: [
|
||||
{ type: "env", name: "EXAMPLE_KEY" },
|
||||
{ type: "credential", id: "cred_1", label: "Work" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("connectionSummary", () => {
|
||||
test("shows credential labels and environment variables", () => {
|
||||
expect(
|
||||
connectionSummary(
|
||||
integration({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_1", label: "Work" },
|
||||
{ type: "env", name: "EXAMPLE_KEY" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("Work, $EXAMPLE_KEY")
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionRequest, QuestionRequest, Session, V2Event } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
|
||||
async function setup() {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
||||
const handlers = new Map<V2Event["type"], ((event: V2Event) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
@@ -33,9 +33,12 @@ async function setup() {
|
||||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
||||
on: <Type extends V2Event["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<V2Event, { type: Type }>) => void,
|
||||
) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: Event) => void
|
||||
const wrapped = handler as (event: V2Event) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
@@ -58,7 +61,7 @@ async function setup() {
|
||||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: Event) {
|
||||
emit(event: V2Event) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
}
|
||||
@@ -83,6 +86,48 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
function stepStarted(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
finish,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stepFailed(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
type: "session.next.step.failed",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
timestamp: 0,
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const questionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Question needs input",
|
||||
@@ -101,8 +146,8 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
||||
})
|
||||
@@ -110,23 +155,23 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") })
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "question.replied",
|
||||
properties: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
data: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
})
|
||||
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") })
|
||||
|
||||
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
type: "permission.replied",
|
||||
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
})
|
||||
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
questionNotification,
|
||||
@@ -139,21 +184,9 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit(stepEnded("event-1"))
|
||||
harness.emit(stepStarted("event-2"))
|
||||
harness.emit(stepEnded("event-3"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
@@ -168,17 +201,9 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") })
|
||||
harness.emit(stepStarted("event-2", "subagent"))
|
||||
harness.emit(stepEnded("event-3", "subagent"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
@@ -199,21 +224,9 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit(stepStarted("event-1"))
|
||||
harness.emit(stepFailed("event-2"))
|
||||
harness.emit(stepEnded("event-3"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
@@ -228,25 +241,17 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("special-cases aborts and model response timeouts", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "abort", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit(stepStarted("event-1", "abort"))
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "timeout", status: { type: "busy" } },
|
||||
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit(stepStarted("event-3", "timeout"))
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
|
||||
@@ -7,9 +7,9 @@ import { ProjectProvider, useProject } from "../../../../src/context/project"
|
||||
import { SDKProvider } from "../../../../src/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../../src/context/sync"
|
||||
import { ExitProvider } from "../../../../src/context/exit"
|
||||
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
|
||||
import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../../fixture/tui-environment"
|
||||
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
|
||||
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
|
||||
|
||||
export async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
@@ -22,8 +22,8 @@ export async function wait(fn: () => boolean, timeout = 2000) {
|
||||
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
|
||||
|
||||
export async function mount(override?: FetchHandler, state?: string) {
|
||||
const calls = createFetch(override)
|
||||
const events = createEventSource()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(override, events)
|
||||
let sync!: ReturnType<typeof useSync>
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let kv!: ReturnType<typeof useKV>
|
||||
@@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) {
|
||||
<TestTuiContexts paths={state ? { state } : undefined}>
|
||||
<ArgsProvider>
|
||||
<KVProvider>
|
||||
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<ExitProvider exit={() => {}}>
|
||||
<SyncProvider>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user