diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 037020c03a..8981aad49a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c69de1d93b..534a8d78a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/bun.lock b/bun.lock index c7ac6ab61c..85a54c847e 100644 --- a/bun.lock +++ b/bun.lock @@ -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:", @@ -105,12 +105,15 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "jsonc-parser": "3.3.1", + "semver": "catalog:", "solid-js": "catalog:", }, "devDependencies": { "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", }, }, @@ -316,6 +319,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 +938,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:", @@ -5973,6 +5978,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], diff --git a/package.json b/package.json index 49507128d6..3bc907d486 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md new file mode 100644 index 0000000000..c7215121e5 --- /dev/null +++ b/packages/cli/AGENTS.md @@ -0,0 +1,98 @@ +# V2 CLI and TUI development guide + +## Migration context + +- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. +- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI. +- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: + +```bash +# From packages/cli: local V2 TUI +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone + +# Released legacy TUI behavior reference +termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest + +termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png +termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png +``` + +- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. +- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. + +## Interactive debugging + +- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. +- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service. +- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. +- Use a dedicated session name and do not reuse or kill an unrelated session. + +```bash +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone +termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 +termctrl show opencode-v2-dev +``` + +- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. +- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. + +```bash +termctrl send opencode-v2-dev 'text:example prompt' enter +termctrl send opencode-v2-dev ctrl-c +``` + +- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. +- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. + +```bash +termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png +``` + +- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: + +```bash +termctrl resize opencode-v2-dev --cols 100 --rows 30 +termctrl show opencode-v2-dev +``` + +- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. +- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`. +- Always clean up the Terminal Control session when the check is complete: + +```bash +termctrl stop opencode-v2-dev +``` + +## Server/API debugging + +- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI. +- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering. +- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path: + +```bash +bun dev api get /health +bun dev api get /openapi.json +bun dev api --param key=value +``` + +- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`. +- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. +- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. + +## Debugger + +- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: + +```bash +termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ + bun run --inspect=ws://localhost:6499/ src/index.ts --standalone +``` + +- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. +- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. + +## Verification + +- Run `bun typecheck` from `packages/cli` after CLI adapter changes. +- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. +- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. diff --git a/packages/cli/bin/lildax.cjs b/packages/cli/bin/opencode2.cjs similarity index 96% rename from packages/cli/bin/lildax.cjs rename to packages/cli/bin/opencode2.cjs index ab99b84b0f..8795c71cd2 100644 --- a/packages/cli/bin/lildax.cjs +++ b/packages/cli/bin/opencode2.cjs @@ -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", ) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1ea11f0864..2ca7a5ad66 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "MIT", "bin": { - "lildax": "./bin/lildax.cjs" + "opencode2": "./bin/opencode2.cjs" }, "files": [ "bin" @@ -25,12 +25,15 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "jsonc-parser": "3.3.1", + "semver": "catalog:", "solid-js": "catalog:" }, "devDependencies": { "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:" } } diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 55869a6295..9c8e1e69b5 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -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 }) diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index d2855413ca..116e5efa32 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -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) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 19d1f5e68b..18db4006a8 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -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)), }, }), ], diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index d0a9968e5d..71d9fe73b6 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,13 +1,27 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Daemon } from "../../services/daemon" +import { Standalone } from "../../services/standalone" +import { Updater } from "../../services/updater" -export default Runtime.handler(Commands, () => +export default Runtime.handler(Commands, (input) => Effect.gen(function* () { + const directory = Option.getOrUndefined(input.directory) + if (directory !== undefined) process.chdir(directory) + const updater = yield* Updater.Service + yield* updater.check({ interactive: true }) 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()) + }, + ) }), ) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index d4ecfed974..87fbece44f 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -6,9 +6,12 @@ import * as Effect from "effect/Effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { createServer } from "node:http" import { createRoutes } from "@opencode-ai/server/routes" +import { ServerAuth } from "@opencode-ai/server/auth" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Daemon } from "../../services/daemon" +import { Updater } from "../../services/updater" export default Runtime.handler( Commands.commands.serve, @@ -16,15 +19,43 @@ export default Runtime.handler( return yield* Effect.scoped( Effect.gen(function* () { const daemon = yield* Daemon.Service - const address = yield* listen(input.hostname, input.port, yield* daemon.password()) + const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD + if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD + const password = input.stdio ? standalonePassword : yield* daemon.password() + if (!password) return yield* Effect.fail(new Error("Missing server password")) + const address = yield* listen(input.hostname, input.port, password) + yield* Effect.tryPromise(() => + createOpencodeClient({ + baseUrl: HttpServer.formatAddress(address), + headers: ServerAuth.headers({ password }), + }).v2.location.get(undefined, { throwOnError: true }), + ) if (input.register) yield* daemon.register(address) - console.log(`server listening on ${HttpServer.formatAddress(address)}`) - return yield* Effect.never - }), + const url = HttpServer.formatAddress(address) + console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) + const updater = yield* Updater.Service + yield* updater.check({ interactive: false }).pipe(Effect.forkScoped) + return yield* (input.stdio ? waitForStdinClose() : Effect.never) + }).pipe(Effect.annotateLogs({ role: "server" })), ) }), ) +function waitForStdinClose() { + return Effect.callback((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, password: string) { if (Option.isSome(port)) return bind(hostname, port.value, password) const next = (port: number): ReturnType => @@ -35,11 +66,15 @@ function listen(hostname: string, port: Option.Option, 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), + ) } diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index d409970e8b..779d456c78 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -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) }), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 97247e4d6b..44ed78ea72 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -2,6 +2,8 @@ import * as Effect from "effect/Effect" import * as Command from "effect/unstable/cli/Command" import { Spec } from "./spec" import { Daemon } from "../services/daemon" +import { Updater } from "../services/updater" +import { Scope } from "effect" export type Input = Value extends Spec.Node @@ -10,11 +12,11 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = (input: unknown) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: (input: Input) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4507fcba6e..f8a95977df 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,10 +2,21 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeServices from "@effect/platform-node/NodeServices" +import { NodeFileSystem } from "@effect/platform-node" import * as Effect from "effect/Effect" +import { Layer, Logger, References } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Daemon } from "./services/daemon" +import { Logging } from "@opencode-ai/core/observability/logging" +import { Updater } from "./services/updater" +import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" + +const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), +) const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), @@ -24,9 +35,14 @@ const Handlers = Runtime.handlers(Commands, { serve: () => import("./commands/handlers/serve"), }) -Runtime.run(Commands, Handlers, { version: "local" }).pipe( +Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe( + Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), + Effect.annotateLogs({ role: "cli" }), Effect.provide(Daemon.defaultLayer), + Effect.provide(Updater.defaultLayer), + Effect.provide(LoggingLayer), Effect.provide(NodeServices.layer), Effect.scoped, + Effect.tap(() => Effect.sync(() => process.exit(0))), NodeRuntime.runMain, ) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 2e1f5bee4e..964822d1d2 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -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] diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts new file mode 100644 index 0000000000..b17005e6d2 --- /dev/null +++ b/packages/cli/src/services/standalone.ts @@ -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" diff --git a/packages/cli/src/services/updater.test.ts b/packages/cli/src/services/updater.test.ts new file mode 100644 index 0000000000..f982c77a10 --- /dev/null +++ b/packages/cli/src/services/updater.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { action, decodePolicy } from "./updater" + +describe("updater", () => { + test("reads autoupdate from JSONC", () => { + expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify") + expect(decodePolicy('{ "autoupdate": false }')).toBe(false) + expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined() + }) + + test("automatically updates patches", () => { + expect(action("1.2.3", "1.2.4", true, false)).toBe("upgrade") + expect(action("1.2.3", "1.2.4", "notify", true)).toBe("confirm") + expect(action("1.2.3", "1.2.4", false, true)).toBe("none") + }) + + test("requires an interactive confirmation for minors", () => { + expect(action("1.2.3", "1.3.0", true, true)).toBe("confirm") + expect(action("1.2.3", "1.3.0", true, false)).toBe("none") + }) + + test("never automatically updates majors", () => { + expect(action("1.2.3", "2.0.0", true, true)).toBe("none") + }) +}) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts new file mode 100644 index 0000000000..b58c590bd8 --- /dev/null +++ b/packages/cli/src/services/updater.ts @@ -0,0 +1,223 @@ +import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppProcess } from "@opencode-ai/core/process" +import { Flock } from "@opencode-ai/core/util/flock" +import { + InstallationChannel, + InstallationLocal, + InstallationVersion, +} from "@opencode-ai/core/installation/version" +import { Context, Duration, Effect, FileSystem, Layer, Option, Path, Schema, Terminal } from "effect" +import { Prompt } from "effect/unstable/cli" +import { ChildProcess } from "effect/unstable/process" +import { parse, type ParseError } from "jsonc-parser" +import path from "node:path" +import semver from "semver" + +export type Policy = boolean | "notify" +export type Action = "none" | "confirm" | "upgrade" +type Method = "npm" | "pnpm" | "bun" | "yarn" + +const packageName = "@opencode-ai/cli" +const checkInterval = 24 * 60 * 60 * 1_000 + +const State = Schema.Struct({ + checked: Schema.Number, + latest: Schema.String, + dismissed: Schema.optional(Schema.String), +}) +type State = typeof State.Type + +export interface Interface { + readonly check: (options: { interactive: boolean }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/cli/Updater") {} + +export function decodePolicy(text: string): Policy | undefined { + // The CLI only projects this host-level preference instead of initializing + // the location-scoped server configuration graph. + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return + const value = input.autoupdate + if (typeof value === "boolean" || value === "notify") return value +} + +export function action(current: string, latest: string, policy: Policy, interactive: boolean): Action { + if (policy === false) return "none" + if (!semver.valid(current) || !semver.valid(latest) || !semver.gt(latest, current)) return "none" + // Major upgrades are never offered or installed automatically. + if (semver.major(latest) !== semver.major(current)) return "none" + if (semver.minor(latest) !== semver.minor(current)) return interactive ? "confirm" : "none" + if (policy === "notify") return interactive ? "confirm" : "none" + return "upgrade" +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const global = yield* Global.Service + const appProcess = yield* AppProcess.Service + const effectPath = yield* Path.Path + const terminal = yield* Terminal.Terminal + const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") + const stateFile = path.join(global.state, `updater-${channel}.json`) + const decodeState = Schema.decodeUnknownOption(Schema.fromJsonString(State)) + + const readPolicy = Effect.fnUntraced(function* () { + const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) => + fs + .readFileString(path.join(global.config, name)) + .pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))), + ) + return values.findLast((value) => value !== undefined) ?? true + }) + + const readState = Effect.fnUntraced(function* () { + const text = yield* fs.readFileString(stateFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!text) return + return Option.getOrUndefined(decodeState(text)) + }) + + const writeState = Effect.fnUntraced(function* (state: State) { + const temp = stateFile + ".tmp" + yield* fs.makeDirectory(global.state, { recursive: true }) + yield* fs.writeFileString(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }) + yield* fs.rename(temp, stateFile) + }) + + const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") { + return yield* appProcess + .run(ChildProcess.make(command[0], command.slice(1)), { + timeout, + maxOutputBytes: 100_000, + maxErrorBytes: 100_000, + }) + .pipe( + Effect.map((result) => ({ + code: result.exitCode, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + })), + Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })), + ) + }) + + const method = Effect.fnUntraced(function* () { + const checks: ReadonlyArray<{ method: Method; command: string[] }> = [ + { method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] }, + { method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] }, + { method: "bun", command: ["bun", "pm", "ls", "-g"] }, + { method: "yarn", command: ["yarn", "global", "list"] }, + ] + const results = yield* Effect.forEach( + checks, + (check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))), + { concurrency: "unbounded" }, + ) + return results.find((result) => result.result.stdout.includes(packageName))?.check.method + }) + + const latest = Effect.fnUntraced(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`, + { headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) }, + ), + catch: (cause) => new Error("Failed to check for updates", { cause }), + }) + if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`)) + const data = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new Error("Failed to read update information", { cause }), + }) + if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") { + return yield* Effect.fail(new Error("Update information did not include a version")) + } + return data.version + }) + + const upgrade = Effect.fnUntraced(function* (method: Method, version: string) { + const target = `${packageName}@${version}` + const commands: Record = { + npm: ["npm", "install", "--global", target], + pnpm: ["pnpm", "install", "--global", target], + bun: ["bun", "install", "--global", target], + yarn: ["yarn", "global", "add", target], + } + const result = yield* run(commands[method], "5 minutes") + if (result.code === 0) return + return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`)) + }) + + const confirm = (version: string) => + Prompt.confirm({ + message: `Update OpenCode from ${InstallationVersion} to ${version}?`, + initial: true, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, effectPath), + Effect.provideService(Terminal.Terminal, terminal), + Effect.orElseSucceed(() => false), + ) + + const check = Effect.fn("cli.updater.check")(function* (options: { interactive: boolean }) { + if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE) + return yield* Effect.logInfo("update check skipped", { + reason: InstallationLocal ? "local-install" : "disabled", + version: InstallationVersion, + channel: InstallationChannel, + }) + const policy = yield* readPolicy() + if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" }) + + return yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(`opencode-cli-updater-${channel}`, { dir: path.join(global.state, "locks") }) + const previous = yield* readState() + const now = Date.now() + const cached = previous && now - previous.checked < checkInterval + const version = cached + ? previous!.latest + : yield* latest().pipe( + Effect.tap((value) => + writeState({ checked: now, latest: value, dismissed: previous?.dismissed }), + ), + ) + yield* Effect.logInfo("update check", { + current: InstallationVersion, + latest: version, + source: cached ? "cached" : "registry", + }) + const next = action( + InstallationVersion, + version, + Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE ? "notify" : policy, + options.interactive && process.stdin.isTTY && process.stdout.isTTY, + ) + if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" }) + if (next === "confirm" && previous?.dismissed === version) + return yield* Effect.logInfo("update check done", { action: "dismissed", version }) + const install = next === "upgrade" || (yield* confirm(version)) + if (!install) { + yield* writeState({ checked: previous?.checked ?? now, latest: version, dismissed: version }) + return yield* Effect.logInfo("update check done", { action: "declined", version }) + } + const detected = yield* method() + if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found") + yield* upgrade(detected, version) + yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected }) + }), + ) + }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause }))) + + return Service.of({ check }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as Updater from "./updater" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 4722441b2c..32a225265c 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -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) { 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 = { - "/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 }, -) diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts new file mode 100644 index 0000000000..6149f614de --- /dev/null +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -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 + }), + ), +) diff --git a/packages/cli/test/standalone.test.ts b/packages/cli/test/standalone.test.ts new file mode 100644 index 0000000000..1a309477f3 --- /dev/null +++ b/packages/cli/test/standalone.test.ts @@ -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) { + 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 { + 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 + } +} diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index bfd965ae35..654712ebb6 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -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" diff --git a/packages/core/package.json b/packages/core/package.json index 139d116a78..74755cd946 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 51c6e279b7..cb54e2c216 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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), diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 7faa107856..f07d72e5f4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -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 { for (const [providerID, item] of Object.entries(providers ?? {})) { catalog.provider.update(providerID, (provider) => { @@ -176,11 +177,13 @@ export const OpencodePlugin = define 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) }), }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index f8d4ef0331..6f83a962cc 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass()( } 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), diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts index 99c208d906..11b84f7905 100644 --- a/packages/core/src/public-event-manifest.ts +++ b/packages/core/src/public-event-manifest.ts @@ -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) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4ce97e133a..9140980361 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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(), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a97afb58b6..46118a89fe 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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 } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 9e29ae71ee..bbeba4e30b 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -287,19 +287,18 @@ export const layer = Layer.effect( } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) + const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause) + const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause) if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) return yield* Effect.interrupt } - if ( - (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) || - (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) - ) { + if (streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) - if (publisher.hasActiveAssistant()) - yield* withPublication(publisher.failAssistant("Provider turn interrupted")) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) @@ -307,7 +306,7 @@ export const layer = Layer.effect( yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) } const stepSettlement = publisher.stepSettlement() - if (stepSettlement && !publisher.hasProviderError()) { + if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) { const endSnapshot = yield* snapshots.capture() const files = startSnapshot && endSnapshot @@ -333,7 +332,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 } }), ) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b864df8ba3..11addb07bb 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -132,7 +132,7 @@ export const fromCatalogModel = ( credential?: Credential.Value, ): Effect.Effect => { const resolved = - credential?.metadata === undefined + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40d..05e8e59fbb 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -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 & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { 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, + } +} diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index f86cbdd69e..19507310c2 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -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}` }))), }), diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 9b12704a22..fb5af18706 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -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 }) }, }), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index eb70f2cb47..1d9a82e952 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -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, Output extends SchemaType> = { +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { readonly description: string readonly input: Input readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type readonly execute: ( input: Schema.Schema.Type, context: Context, @@ -59,10 +68,12 @@ type Runtime = { const runtimes = new WeakMap() -export function make, Output extends SchemaType>( - config: Config, -): Definition { - const tool = Object.freeze({}) as Definition +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -72,7 +83,7 @@ export function make, Output extends SchemaType, Output extends SchemaType 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, Output extends SchemaType ({ - structured: output, + Effect.map(({ output, structured }) => ({ + structured, content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index 9506a6a2b0..6bf0bd9e1a 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -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 diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 3e23e20c70..836975937a 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -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" } } }], }, }, }) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index 3edd9ed074..44a1c0c9db 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -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" }, }) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 30757a042c..45d423f958 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -25,6 +25,20 @@ function required(value: T | undefined): T { return value } +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + 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(vars: Record, effect: () => Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { @@ -67,11 +81,14 @@ describe("OpencodePlugin", () => { Effect.acquireUseRelease( Effect.sync(() => { const authorization: Array = [] + const gate = Promise.withResolvers() 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", diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index 580ff0fbf7..fe16936117 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -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* () { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 7826d6bf28..66ee4df9fa 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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 diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 94669b24be..49bbce95a3 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -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( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c9594e8244..ae83a733b7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -371,6 +371,18 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") => const userTexts = (request: LLMRequest) => messageTexts(request, "user") const systemTexts = (request: LLMRequest) => messageTexts(request, "system") +const recordedEventTypes = (id: SessionV2.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie, Effect.map((rows) => rows.map((row) => row.type))) + }) + const replaySessionProjection = (id: SessionV2.ID) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -2565,7 +2577,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 +2591,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 +2620,7 @@ describe("SessionRunnerLLM", () => { }, ], }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) }), ) @@ -2762,6 +2784,11 @@ describe("SessionRunnerLLM", () => { expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(requests).toHaveLength(1) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Interrupt provider" }, + { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } }, + ]) + expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2") yield* session.interrupt(sessionID) }), ) @@ -2791,6 +2818,8 @@ describe("SessionRunnerLLM", () => { { type: "user", text: "Interrupt tool settlement" }, { type: "assistant", + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, content: [ { type: "tool", @@ -2800,6 +2829,9 @@ describe("SessionRunnerLLM", () => { ], }, ]) + const eventTypes = yield* recordedEventTypes(sessionID) + expect(eventTypes).toContain("session.next.step.failed.2") + expect(eventTypes).not.toContain("session.next.step.ended.2") }), ) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378e..9b666f0917 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -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: ["*"] }, diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 9bbea5f0c3..f15621ef58 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -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, }) }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index d8f96a5803..f95cd1c923 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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: ["*"] }]) diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index f07170c585..7e7052bd07 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -1,131 +1,5 @@ -# opencode database guide +# opencode package guidance -## Database +`packages/opencode` is the V1 version of this project. -- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`. -- **Migrations**: database migrations live in `packages/core` and are applied by core. - -## Development server - -- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result. -- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`. -- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`. -- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`. - -# Module shape - -Do not use `export namespace Foo { ... }` for module organization. It is not -standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript -runner. Use flat top-level exports combined with a self-reexport at the bottom -of the file: - -```ts -// src/foo/foo.ts -export interface Interface { ... } -export class Service extends Context.Service()("@opencode/Foo") {} -export const layer = Layer.effect(Service, ...) -export const defaultLayer = layer.pipe(...) - -export * as Foo from "./foo" -``` - -Consumers import the namespace projection: - -```ts -import { Foo } from "@/foo/foo" - -yield * Foo.Service -Foo.layer -Foo.defaultLayer -``` - -Namespace-private helpers stay as non-exported top-level declarations in the -same file — they remain inaccessible to consumers (they are not projected by -`export * as`) but are usable by the file's own code. - -## When the file is an `index.ts` - -If the module is `foo/index.ts` (single-namespace directory), use `"."` for -the self-reexport source rather than `"./index"`: - -```ts -// src/foo/index.ts -export const thing = ... - -export * as Foo from "." -``` - -## Multi-sibling directories - -For directories with several independent modules (e.g. `src/session/`, -`src/config/`), keep each sibling as its own file with its own self-reexport, -and do not add a barrel `index.ts`. Consumers import the specific sibling: - -```ts -import { SessionRetry } from "@/session/retry" -import { SessionStatus } from "@/session/status" -``` - -Barrels in multi-sibling directories force every import through the barrel to -evaluate every sibling, which defeats tree-shaking and slows module load. - -# opencode Effect rules - -Use these rules when writing or migrating Effect code. - -See `specs/effect/migration.md` for the compact pattern reference and examples. - -## Core - -- Use `Effect.gen(function* () { ... })` for composition. -- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. -- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers. -- Use `Effect.callback` for callback-based APIs. -- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`. -- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. - -## Module conventions - -- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. - -## Schemas and errors - -- Use `Schema.Class` for multi-field data. -- Use branded schemas (`Schema.brand`) for single-value types. -- Use `Schema.TaggedErrorClass` for typed errors. -- Use `Schema.Defect` instead of `unknown` for defect-like causes. -- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. - -## Runtime vs InstanceState - -- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers. -- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal. -- If two open directories should not share one copy of the service, it needs `InstanceState`. -- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top. -- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.). -- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed. -- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it. -- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency. - -## Effect v4 beta API - -- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope. - -## Preferred Effect services - -- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. -- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O. -- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers. -- Prefer `HttpClient.HttpClient` instead of raw `fetch`. -- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code. -- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. - -## Effect.cached for deduplication - -Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern. - -## Callback boundaries - -Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context. - -Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims. +We are moving to V2, which is split across the `core`, `tui`, and `cli` packages. It is okay to read code in this package to understand how V1 worked, but do not make changes here unless explicitly instructed. diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 6f5aea6a12..288bc38ec6 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -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 ", @@ -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, }), ) }, diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 43176ded79..be814dbbfa 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio () => ( - {input.agent} {" "} diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts index 95284dfa0c..aadac67afc 100644 --- a/packages/opencode/src/cli/cmd/run/turn-summary.ts +++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts @@ -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: { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 329874791d..8063656e68 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -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> - -function createWorkerFetch(client: RpcClient): typeof fetch { - const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - 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("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, diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446..c03b3b7076 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -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> | undefined export const rpc = { - async fetch(input: { url: string; method: string; headers: Record; 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 diff --git a/packages/opencode/src/plugin/tui/internal.ts b/packages/opencode/src/plugin/tui/internal.ts index cc33b15bee..1dd2213136 100644 --- a/packages/opencode/src/plugin/tui/internal.ts +++ b/packages/opencode/src/plugin/tui/internal.ts @@ -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): InternalTuiPlugin[] { - return createBuiltinPlugins({ - experimentalEventSystem: flags.experimentalEventSystem, - }) +export function internalTuiPlugins(): InternalTuiPlugin[] { + return createBuiltinPlugins() } diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 4673805cf3..1e6cc696f2 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -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, { diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 52ff5a354d..7406d36cbb 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -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) } diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index e3356d1831..7f0f272d63 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -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", }), ]) diff --git a/packages/opencode/test/fixture/tui-sdk.ts b/packages/opencode/test/fixture/tui-sdk.ts deleted file mode 100644 index b5bbbe4f14..0000000000 --- a/packages/opencode/test/fixture/tui-sdk.ts +++ /dev/null @@ -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 | 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 } -} diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 6781216947..f218f8ec3f 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -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: Type, handler: (event: Extract) => void) => () => void + on: ( + type: Type, + handler: (event: Extract) => void, + ) => () => void } export type TuiDispose = () => void | Promise diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index f32bb0582e..58ff532063 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -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( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 98335fcab0..ac3abe6389 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3926,6 +3926,10 @@ export type SessionMessageAssistantReasoning = { id: string text: string providerMetadata?: LlmProviderMetadata + time?: { + created: number + completed?: number + } } export type SessionMessageToolStatePending = { diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 8001fb8748..590e84a4cd 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -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)(event.data), + } + : event return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(encoded), } } diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 4af05c2584..2c60124304 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -969,7 +969,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
Promise args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise - 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) { > @@ -289,13 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -366,10 +361,12 @@ function App(props: { onSnapshot?: () => Promise; 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; pluginHost: TuiPlugi event, sdk, sync, + data, theme: themeState, toast, renderer, @@ -494,9 +492,7 @@ function App(props: { onSnapshot?: () => Promise; 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; 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(() => ) - }, - ), - ) - const connected = useConnected() const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() @@ -563,7 +548,7 @@ function App(props: { onSnapshot?: () => Promise; 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; pluginHost: TuiPlugi }, { name: "provider.connect", - title: "Connect provider", + title: "Connect integration", suggested: !connected(), slashName: "connect", run: () => { - dialog.replace(() => ) + dialog.replace(() => ) }, - category: "Provider", + category: "Integration", }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ @@ -761,6 +746,33 @@ function App(props: { onSnapshot?: () => Promise; 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; 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; 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; 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; 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; pluginHost: TuiPlugi + + + ) } diff --git a/packages/tui/src/component/dialog-agent.tsx b/packages/tui/src/component/dialog-agent.tsx index 3d2928910b..f586cb0780 100644 --- a/packages/tui/src/component/dialog-agent.tsx +++ b/packages/tui/src/component/dialog-agent.tsx @@ -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 ( { local.agent.set(option.value) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx new file mode 100644 index 0000000000..0939fedcda --- /dev/null +++ b/packages/tui/src/component/dialog-integration.tsx @@ -0,0 +1,463 @@ +import { TextAttributes } from "@opentui/core" +import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2" +import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import { useClipboard } from "../context/clipboard" +import { useData } from "../context/data" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { useBindings } from "../keymap" +import { useDialog } from "../ui/dialog" +import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogSelect } from "../ui/dialog-select" +import { Link } from "../ui/link" +import { useToast } from "../ui/toast" + +const INTEGRATION_PRIORITY: Record = { + opencode: 0, + "opencode-go": 1, + openai: 2, + "github-copilot": 3, + anthropic: 4, + google: 5, +} + +type ConnectMethod = Exclude + +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 => 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 ? () => : undefined, + onSelect: () => + credentialConnections(integration).length + ? manageConnections(integration, methods, dialog) + : selectMethod(integration, methods, dialog), + } + }), + ) + + return ( + No integrations available} + /> + ) +} + +function manageConnections( + integration: IntegrationInfo, + methods: ConnectMethod[], + dialog: ReturnType, +) { + dialog.replace(() => { + const data = useData() + const sdk = useSDK() + const toast = useToast() + return ( + 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, +) { + if (methods.length === 1) return openMethod(integration, methods[0], dialog) + dialog.replace(() => ( + ({ + 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, +) { + if (method.type === "key") { + dialog.replace(() => ) + return + } + void beginOAuth(integration, method, dialog) +} + +function KeyMethod(props: { integration: IntegrationInfo; method: Extract }) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const toast = useToast() + const { theme } = useTheme() + const [error, setError] = createSignal() + + return ( + { + 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={() => ( + {(value) => {value()}} + )} + /> + ) +} + +async function beginOAuth( + integration: IntegrationInfo, + method: IntegrationOAuthMethod, + dialog: ReturnType, +) { + const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} + if (inputs === null) return + dialog.replace(() => ) +} + +function OAuthStarting(props: { + integration: IntegrationInfo + method: IntegrationOAuthMethod + inputs: Record +}) { + 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(() => ( + + )) + return + } + dialog.replace(() => ( + + )) + }) + .catch((cause) => { + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + }) + + return +} + +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 | undefined + let settled = false + + useBindings(() => ({ + bindings: [ + { + key: "c", + desc: "Copy authorization details", + group: "Dialog", + cmd: () => { + const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url + clipboard + .write?.(value) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], + })) + + const poll = () => { + void sdk.client.v2.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true }) + .then((result) => { + const status = result.data.data + if (status.status === "pending") { + timer = setTimeout(poll, 500) + return + } + settled = true + if (status.status === "complete") { + void connected(props.integration.name, data, dialog, toast) + return + } + toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) + dialog.clear() + }) + .catch((cause) => { + settled = true + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + } + + onMount(poll) + onCleanup(() => { + if (timer) clearTimeout(timer) + if (settled) return + void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + }) + + return ( + + ) +} + +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() + let settled = false + + onCleanup(() => { + if (settled) return + void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + }) + + return ( + { + 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={() => ( + + {props.attempt.instructions} + + {(value) => {value()}} + + )} + /> + ) +} + +function OAuthView(props: { + title: string + url?: string + instructions?: string + message: string + copy?: boolean +}) { + const dialog = useDialog() + const { theme } = useTheme() + return ( + + + + {props.title} + + dialog.clear()}> + esc + + + + {(url) => ( + + + {(instructions) => {instructions()}} + + )} + + {props.message} + + + c copy + + + + ) +} + +async function promptInputs( + dialog: ReturnType, + prompts: NonNullable, +) { + const inputs: Record = {} + 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((resolve) => { + dialog.replace( + () => ( + ({ + 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((resolve) => { + dialog.replace( + () => , + () => resolve(null), + ) + }) + if (value === null) return null + inputs[prompt.key] = value + } + return inputs +} + +async function connected( + name: string, + data: ReturnType, + dialog: ReturnType, + toast: ReturnType, +) { + 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, + dialog: ReturnType, + toast: ReturnType, +) { + 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) { + 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" +} diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 38db569aaa..065338f1cd 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -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(() => ) + dialog.replace(() => ) }, }, { @@ -195,3 +163,7 @@ export function sortModelOptions option.title, ) } + +function free(model: { cost: Array<{ input: number }> }) { + return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0) +} diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index e0d5508736..b4fb2f2b45 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { async (projectID, info): Promise => { 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, }) diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 2767508eb2..0fd51e3c1c 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) { + ({ opencode: ( @@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) { ), - }[props.providerID] ?? undefined + })[props.providerID] ?? undefined } onConfirm={async (value) => { if (!value) return diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index c508fc6b7b..398f4bf560 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -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(input: { - search?: string - filter: SessionListFilter - list: (query: ReturnType) => 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() - const [deleted, setDeleted] = createSignal(new Set()) 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[number]>) { - const workspace = project.workspace.get(session.workspaceID!) - const list = () => dialog.replace(() => ) - 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(() => ( - { - 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>) { - 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(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 - ? () => - : slot !== undefined - ? () => {slot} - : 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" + ? () => + : slot === undefined + ? undefined + : () => {slot}, } } - 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 ( { - 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(() => ) - }, + onTrigger: () => unavailable("Renaming"), }, ]} footerHints={quickSwitchFooterHints()} diff --git a/packages/tui/src/component/dialog-variant.tsx b/packages/tui/src/component/dialog-variant.tsx index cbff7cd4fe..1ca224254d 100644 --- a/packages/tui/src/component/dialog-variant.tsx +++ b/packages/tui/src/component/dialog-variant.tsx @@ -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 ( options={options()} title={"Select variant"} - current={local.model.variant.selected()} + current={local.model.variant.current()} flat={true} /> ) diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 98c71bb000..23e4a8df3a 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: { sync: ReturnType toast: ReturnType }) { - 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 diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d83e..28ebef05dc 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -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, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aa002080b1..4fb2677e16 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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(() => ) + if (!connected()) { + dialog.replace(() => ) } } @@ -233,7 +241,7 @@ export function Prompt(props: PromptProps) { event.on("tui.prompt.append", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return if (!input || input.isDestroyed) return - input.insertText(evt.properties.text) + input.insertText(evt.data.text) setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return @@ -303,28 +311,20 @@ export function Prompt(props: PromptProps) { ), ) - // Initialize agent/model/variant from last user message when session changes + // Initialize agent/model/variant from the durable V2 Session state. let syncedSessionID: string | undefined createEffect(() => { const sessionID = props.sessionID - const msg = lastUserMessage() - - if (sessionID !== syncedSessionID) { - if (!sessionID || !msg) return - - syncedSessionID = sessionID - - // Only set agent if it's a primary agent (not a subagent) - const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) - if (msg.agent && isPrimaryAgent) { - // Keep command line --agent if specified. - if (!args.agent) local.agent.set(msg.agent) - if (msg.model) { - local.model.set(msg.model) - local.model.variant.set(msg.model.variant) - } - } + if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return + const session = data.session.get(sessionID) + if (!session) return + const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) + if (agent && !args.agent) local.agent.set(agent.id) + if (session.model) { + local.model.set({ providerID: session.model.providerID, modelID: session.model.id }) + local.model.variant.set(session.model.variant) } + syncedSessionID = sessionID }) const promptCommands = createMemo(() => @@ -389,7 +389,7 @@ export function Prompt(props: PromptProps) { name: "session.interrupt", category: "Session", hidden: true, - enabled: status().type !== "idle", + enabled: status() === "running", run: () => { if (auto()?.visible) return if (!input.focused) return @@ -407,7 +407,7 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { - void sdk.client.session.abort({ + void sdk.client.v2.session.interrupt({ sessionID: props.sessionID, }) setStore("interrupt", 0) @@ -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) => ( <> - {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)} @@ -1501,78 +1533,20 @@ export function Prompt(props: PromptProps) { - - - - - [⋯]}> - - - - - {(() => { - 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 ( - - - {retryText()} - - - ) - })()} - + + + + [⋯]}> + + + + {(count) => ( + + {count()} active subagent{count() === 1 ? "" : "s"} + + )} + 0 ? theme.primary : theme.text}> esc{" "} 0 ? theme.primary : theme.textMuted }}> @@ -1633,41 +1607,39 @@ export function Prompt(props: PromptProps) { {props.hint ?? } - - - - {(file) => ( - {file()} - )} - - - - - - {(item) => ( - - {[item().context, item().cost].filter(Boolean).join(" · ")} - - )} - - - - {agentShortcut()} agents + + + {(file) => ( + {file()} + )} + + + + + + {(item) => ( + + {[item().context, item().cost].filter(Boolean).join(" · ")} - - - - {paletteShortcut()} commands - - - - - esc exit shell mode - - - - - + )} + + + + {agentShortcut()} agents + + + + + {paletteShortcut()} commands + + + + + esc exit shell mode + + + + 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, diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx new file mode 100644 index 0000000000..2ebbda9909 --- /dev/null +++ b/packages/tui/src/component/reconnecting.tsx @@ -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 ( + + + Connection lost + Reconnecting to server... + Attempt {props.attempt} + + + {props.error} + + + + + ) +} diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index f59e95c05f..4a54ae68e1 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -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, ) } diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index c58189552d..4dbf872750 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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("a", "List agents"), agent_cycle: keybind("tab", "Next agent"), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9b2e58907a..9e92eb7a89 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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 + status: Record message: Record permission: Record question: Record @@ -61,6 +63,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const [store, setStore] = createStore({ 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({ - directory: sdk.directory ?? process.cwd(), + directory: process.cwd(), }) + const messageIndex = new Map>() const message = { - update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => 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, 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, 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() + 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 }, diff --git a/packages/tui/src/context/event.ts b/packages/tui/src/context/event.ts index 1060f3ab17..ed4685168c 100644 --- a/packages/tui/src/context/event.ts +++ b/packages/tui/src/context/event.ts @@ -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( + function on( type: T, - handler: (event: Extract, metadata: EventMetadata) => void, + handler: (event: Extract, metadata: EventMetadata) => void, ) { - return subscribe((event: Event, metadata: EventMetadata) => { - if (event.type !== type) return - handler(event as Extract, metadata) + return sdk.event.on(type, (event) => { + handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID }) }) } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 39e7bc111d..389d66dc9f 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -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, }) }) diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index d73a17e7ec..a613078306 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -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, diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 93180c6e21..751f57bd13 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -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 } +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 }) => { 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() + const [connection, setConnection] = createStore<{ + status: SDKConnectionStatus + attempt: number + error?: string + }>({ + status: "connecting", + attempt: 0, }) + let stream: AbortController | undefined + let pending: Promise | undefined + function start() { + stream?.abort() + const controller = new AbortController() + const current = client + let connected!: () => void + const ready = new Promise((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((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) +} diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 03db8784de..7e691ad29c 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -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(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 agent: Agent[] command: Command[] - permission: { - [sessionID: string]: PermissionRequest[] - } - question: { - [sessionID: string]: QuestionRequest[] - } + permission: Record + question: Record 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 + todo: Record + message: Record + part: Record lsp: LspStatus[] - mcp: { - [key: string]: McpStatus - } - mcp_resource: { - [key: string]: McpResource - } + mcp: Record + mcp_resource: Record 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() - const syncingSessions = new Map>() - const hydratingSessions = new Map; parts: Set }>() - 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(), parts: new Set() } - 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 }, }) diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index b67923f3c5..569f3db020 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -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 & { id: string @@ -18,7 +20,7 @@ export type BuiltinTuiPlugin = Omit & { 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[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() + } +} diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index fbadacce92..81c696ef13 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -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["properties"]["error"] +type SessionError = Extract["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() 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") }) } diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx new file mode 100644 index 0000000000..7327ef71cd --- /dev/null +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -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 ( + + + + ~/code/anomalyco/opencode + + esc home + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.route.register([{ name: route, render: () => }]) + 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 diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fef0ec8eb8..068b51c732 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -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 sdk: ReturnType sync: ReturnType + data: ReturnType theme: ReturnType toast: ReturnType renderer: TuiPluginApi["renderer"] @@ -95,7 +97,7 @@ function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { return (item: SelectOption) => cb(pickOption(item)) } -function stateApi(sync: ReturnType): TuiPluginApi["state"] { +function stateApi(sync: ReturnType, data: ReturnType): TuiPluginApi["state"] { return { get ready() { return sync.ready @@ -135,7 +137,7 @@ function stateApi(sync: ReturnType): 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 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 ( { - 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() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f832174aa4..d6f1e96884 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -11,31 +11,28 @@ import { onMount, Show, Switch, - untrack, useContext, } from "solid-js" -import { Dynamic } from "solid-js/web" import path from "node:path" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" -import { useSync } from "../../context/sync" -import { useEvent } from "../../context/event" +import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" -import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" +import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { - AssistantMessage, - Part, - Provider, - ToolPart, - UserMessage, - TextPart, - ReasoningPart, - SessionStatus, + ModelV2Info, + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageUser, + SessionV2Info, } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" @@ -45,14 +42,8 @@ import { useSDK } from "../../context/sdk" import { useEditorContext } from "../../context/editor" import { openEditor } from "../../editor" import { useDialog } from "../../ui/dialog" -import { DialogAlert } from "../../ui/dialog-alert" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" -import type { PromptInfo } from "../../component/prompt/history" -import { DialogConfirm } from "../../ui/dialog-confirm" -import { DialogTimeline } from "./dialog-timeline" -import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" -import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" @@ -67,52 +58,20 @@ import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" -import * as Model from "../../util/model" -import { formatTranscript } from "../../util/transcript" import { sessionEpilogue } from "../../util/presentation" -import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" -import { DialogRetryAction } from "../../component/dialog-retry-action" -import { getRevertDiffFiles } from "../../util/revert-diff" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" +import { createSessionRows, type PartRef, type SessionRow } from "./rows" addDefaultParsers(parsers.parsers) -const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at" -const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show" -const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at" -const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" -const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs -const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) - -export const alwaysSeparate = new WeakSet() - -type RetryAction = Extract["action"] - -function goUpsellKeys(action: RetryAction) { - if (!action) return - if (!GO_UPSELL_PROVIDERS.has(action.provider)) return - if (action.reason === "free_tier_limit") { - return { - lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT, - dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW, - } - } - if (action.reason === "account_rate_limit") { - return { - lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, - dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, - } - } -} - const sessionBindingCommands = [ "session.share", "session.rename", @@ -129,6 +88,7 @@ const sessionBindingCommands = [ "session.toggle.actions", "session.toggle.scrollbar", "session.toggle.generic_tool_output", + "session.toggle.exploration_grouping", "session.first", "session.last", "session.messages_last_user", @@ -137,6 +97,7 @@ const sessionBindingCommands = [ "messages.copy", "session.copy", "session.export", + "session.background", "session.child.first", "session.parent", "session.child.next", @@ -163,9 +124,9 @@ const context = createContext<{ showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean + groupExploration: () => boolean diffWrapMode: () => "word" | "none" - providers: () => ReadonlyMap - sync: ReturnType + models: () => ModelV2Info[] tui: ReturnType }>() @@ -185,64 +146,46 @@ export function Session() { const pluginRuntime = usePluginRuntime() const route = useRouteData("session") const { navigate } = useRoute() - const sync = useSync() - const event = useEvent() + const data = useData() const project = useProject() const paths = useTuiPaths() const tuiConfig = useTuiConfig() const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() - const session = createMemo(() => sync.session.get(route.sessionID)) - const location = createMemo(() => { - const current = session() - return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined + const session = createMemo(() => data.session.get(route.sessionID)) + const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) + const sessionMessages = () => messageIDs().flatMap((id) => { + const message = data.session.message.get(route.sessionID, id) + return message ? [message] : [] }) + const location = createMemo(() => session()?.location) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) }) onCleanup(() => setEpilogue()) - const children = createMemo(() => { - const parentID = session()?.parentID ?? session()?.id - return sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }) - const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) - const foregroundTasks = createMemo(() => - sync.data.capabilities.experimentalBackgroundSubagents - ? messages().flatMap((message) => - (sync.data.part[message.id] ?? []).filter( - (part): part is ToolPart => - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - part.state.metadata?.background !== true, - ), - ) - : [], - ) + const messages = sessionMessages const permissions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return data.session.permission.list(route.sessionID) ?? [] }) const questions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return data.session.question.list(route.sessionID) ?? [] }) const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { - const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id - return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed)) + const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id + return messages().findLast((x) => x.type === "assistant" && !x.time.completed && (!completed || x.id > completed)) ?.id }) const lastAssistant = createMemo(() => { - return messages().findLast((x) => x.role === "assistant") + return messages().findLast((x) => x.type === "assistant") }) const dimensions = useTerminalDimensions() @@ -259,6 +202,7 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -269,19 +213,24 @@ export function Session() { }) const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) - const providers = createMemo(() => Model.index(sync.data.provider)) + const models = createMemo(() => data.location.model.list(location()) ?? []) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const toast = useToast() const sdk = useSDK() const editor = useEditorContext() + const rows = createSessionRows(() => route.sessionID) createEffect(() => { const sessionID = route.sessionID void (async () => { - const previousWorkspace = untrack(() => project.workspace.current()) - const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) - if (!result.data) { + await Promise.all([ + data.session.refresh(sessionID), + data.session.permission.refresh(sessionID), + data.session.question.refresh(sessionID), + ]) + const info = data.session.get(sessionID) + if (!info) { toast.show({ message: `Session not found: ${sessionID}`, variant: "error", @@ -291,19 +240,8 @@ export function Session() { return } - if (result.data.workspaceID !== previousWorkspace) { - project.workspace.set(result.data.workspaceID) - - // Sync all the data for this workspace. Note that this - // workspace may not exist anymore which is why this is not - // fatal. If it doesn't we still want to show the session - // (which will be non-interactive) - try { - await sync.bootstrap({ fatal: false }) - } catch {} - } - editor.reconnect(result.data.directory) - await sync.session.sync(sessionID) + project.workspace.set(info.location.workspaceID) + editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { if (route.sessionID !== sessionID) return @@ -316,23 +254,6 @@ export function Session() { }) }) - let lastSwitch: string | undefined = undefined - event.on("message.part.updated", (evt) => { - const part = evt.properties.part - if (part.type !== "tool") return - if (part.sessionID !== route.sessionID) return - if (part.state.status !== "completed") return - if (part.id === lastSwitch) return - - if (part.tool === "plan_exit") { - local.agent.set("build") - lastSwitch = part.id - } else if (part.tool === "plan_enter") { - local.agent.set("plan") - lastSwitch = part.id - } - }) - let seeded = false let scroll: ScrollBoxRenderable let prompt: PromptRef | undefined @@ -343,29 +264,12 @@ export function Session() { seeded = true r.set(route.prompt) } - const keymap = useOpencodeKeymap() const dialog = useDialog() const renderer = useRenderer() - - event.on("session.status", (evt) => { - if (evt.properties.sessionID !== route.sessionID) return - if (evt.properties.status.type !== "retry") return - if (!evt.properties.status.action) return - if (dialog.stack.length > 0) return - - const keys = goUpsellKeys(evt.properties.status.action) - if (!keys) return - - const seen = kv.get(keys.lastSeenAt) - if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return - - if (kv.get(keys.dontShow)) return - - void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { - if (dontShowAgain) kv.set(keys.dontShow, true) - kv.set(keys.lastSeenAt, Date.now()) - }) - }) + const unavailable = (feature: string) => { + toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) + dialog.clear() + } // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { @@ -380,11 +284,8 @@ export function Session() { const message = messagesList.find((m) => m.id === c.id) if (!message) return false - // Check if message has valid non-synthetic, non-ignored text parts - const parts = sync.data.part[message.id] - if (!parts || !Array.isArray(parts)) return false - - return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored) + if (message.type === "user") return Boolean(message.text.trim()) + return message.type === "assistant" && message.content.some((content) => content.type === "text" && content.text.trim()) }) .sort((a, b) => a.y - b.y) @@ -420,136 +321,35 @@ export function Session() { }, 50) } - const local = useLocal() - - function enterChild(sessionID: string) { - navigate({ - type: "session", - sessionID, - }) - const status = sync.data.session_status[sessionID] - if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message) - } - - function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) - if (next) enterChild(next.id) - } - - function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) - direction - - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) enterChild(sessions[next].id) - } - - function childSessionHandler(func: () => void) { - return () => { - if (!session()?.parentID || dialog.stack.length > 0) return - func() - } - } - const sessionCommandList = createMemo(() => [ { - title: session()?.share?.url ? "Copy share link" : "Share session", + title: "Share session", value: "session.share", suggested: route.type === "session", category: "Session", - enabled: sync.data.config.share !== "disabled", - slash: { - name: "share", - }, - run: async () => { - const copy = (url: string) => - clipboard - .write?.(url) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) - const url = session()?.share?.url - if (url) { - await copy(url) - dialog.clear() - return - } - if (!kv.get("share_consent", false)) { - const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?") - if (ok !== true) return - kv.set("share_consent", true) - } - await sdk.client.session - .share({ - sessionID: route.sessionID, - }) - .then((res) => copy(res.data!.share!.url)) - .catch((error) => { - toast.show({ - message: error instanceof Error ? error.message : "Failed to share session", - variant: "error", - }) - }) - dialog.clear() - }, + slash: { name: "share" }, + run: () => unavailable("Sharing"), }, { title: "Rename session", value: "session.rename", category: "Session", - slash: { - name: "rename", - }, - run: () => { - dialog.replace(() => ) - }, + slash: { name: "rename" }, + run: () => unavailable("Renaming"), }, { title: "Jump to message", value: "session.timeline", category: "Session", - slash: { - name: "timeline", - }, - run: () => { - dialog.replace(() => ( - { - const child = scroll.getChildren().find((child) => { - return child.id === messageID - }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) - }} - sessionID={route.sessionID} - setPrompt={(promptInfo) => prompt?.set(promptInfo)} - /> - )) - }, + slash: { name: "timeline" }, + run: () => unavailable("The message timeline"), }, { title: "Fork session", value: "session.fork", category: "Session", - slash: { - name: "fork", - }, - run: () => { - dialog.replace(() => ( - { - if (!messageID) return - const child = scroll.getChildren().find((child) => { - return child.id === messageID - }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) - }} - sessionID={route.sessionID} - /> - )) - }, + slash: { name: "fork" }, + run: () => unavailable("Forking"), }, { title: "Compact session", @@ -560,20 +360,7 @@ export function Session() { aliases: ["summarize"], }, run: () => { - const selectedModel = local.model.current() - if (!selectedModel) { - toast.show({ - variant: "warning", - message: "Connect a provider to summarize this session", - duration: 3000, - }) - return - } - void sdk.client.session.summarize({ - sessionID: route.sessionID, - modelID: selectedModel.modelID, - providerID: selectedModel.providerID, - }) + void sdk.client.v2.session.compact({ sessionID: route.sessionID }) dialog.clear() }, }, @@ -581,87 +368,24 @@ export function Session() { title: "Unshare session", value: "session.unshare", category: "Session", - enabled: !!session()?.share?.url, - slash: { - name: "unshare", - }, - run: async () => { - await sdk.client.session - .unshare({ - sessionID: route.sessionID, - }) - .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) - .catch((error) => { - toast.show({ - message: error instanceof Error ? error.message : "Failed to unshare session", - variant: "error", - }) - }) - dialog.clear() - }, + enabled: false, + slash: { name: "unshare" }, + run: () => unavailable("Unsharing"), }, { title: "Undo previous message", value: "session.undo", category: "Session", - slash: { - name: "undo", - }, - run: async () => { - const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) - const revert = session()?.revert?.messageID - const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") - if (!message) return - void sdk.client.session - .revert({ - sessionID: route.sessionID, - messageID: message.id, - }) - .then(() => { - toBottom() - }) - const parts = sync.data.part[message.id] - prompt?.set( - parts.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"] }, - ), - ) - dialog.clear() - }, + slash: { name: "undo" }, + run: () => unavailable("Undo"), }, { title: "Redo", value: "session.redo", category: "Session", enabled: !!session()?.revert?.messageID, - slash: { - name: "redo", - }, - run: () => { - dialog.clear() - const messageID = session()?.revert?.messageID - if (!messageID) return - const message = messages().find((x) => x.role === "user" && x.id > messageID) - if (!message) { - void sdk.client.session.unrevert({ - sessionID: route.sessionID, - }) - prompt?.set({ input: "", parts: [] }) - return - } - void sdk.client.session.revert({ - sessionID: route.sessionID, - messageID: message.id, - }) - }, + slash: { name: "redo" }, + run: () => unavailable("Redo"), }, { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", @@ -742,6 +466,15 @@ export function Session() { dialog.clear() }, }, + { + title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools", + value: "session.toggle.exploration_grouping", + category: "Session", + run: () => { + setGroupExploration((prev) => !prev) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", @@ -828,22 +561,14 @@ export function Session() { category: "Session", hidden: true, run: () => { - const messages = sync.data.message[route.sessionID] + const messages = sessionMessages() if (!messages || !messages.length) return // Find the most recent user message with non-ignored, non-synthetic text parts for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] - if (!message || message.role !== "user") continue - - const parts = sync.data.part[message.id] - if (!parts || !Array.isArray(parts)) continue - - const hasValidTextPart = parts.some( - (part) => part && part.type === "text" && !part.synthetic && !part.ignored, - ) - - if (hasValidTextPart) { + if (!message || message.type !== "user" || !message.text.trim()) continue + { const child = scroll.getChildren().find((child) => { return child.id === message.id }) @@ -874,7 +599,7 @@ export function Session() { run: () => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( - (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), + (msg): msg is SessionMessageAssistant => msg.type === "assistant" && (!revertID || msg.id < revertID), ) if (!lastAssistantMessage) { toast.show({ message: "No assistant messages found", variant: "error" }) @@ -882,8 +607,7 @@ export function Session() { return } - const parts = sync.data.part[lastAssistantMessage.id] ?? [] - const textParts = parts.filter((part) => part.type === "text") + const textParts = lastAssistantMessage.content.filter((part) => part.type === "text") if (textParts.length === 0) { toast.show({ message: "No text parts found in last assistant message", variant: "error" }) dialog.clear() @@ -921,17 +645,7 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const sessionMessages = messages() - const transcript = formatTranscript( - sessionData, - sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), - { - thinking: showThinking(), - toolDetails: showDetails(), - assistantMetadata: showAssistantMetadata(), - providers: sync.data.provider, - }, - ) + const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails()) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -951,8 +665,6 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const sessionMessages = messages() - const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` const options = await DialogExportOptions.show( @@ -966,16 +678,7 @@ export function Session() { if (options === null) return - const transcript = formatTranscript( - sessionData, - sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), - { - thinking: options.thinking, - toolDetails: options.toolDetails, - assistantMetadata: options.assistantMetadata, - providers: sync.data.provider, - }, - ) + const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails) if (options.openWithoutSaving) { // Just open in editor without saving @@ -1020,24 +723,14 @@ export function Session() { value: "session.background", category: "Session", hidden: true, - enabled: foregroundTasks().length > 0, - run: () => { - void sdk.client.experimental.session.background({ - sessionID: route.sessionID, - workspace: project.workspace.current(), - }) - dialog.clear() - }, + run: () => unavailable("Backgrounding subagents"), }, { title: "Go to child session", value: "session.child.first", category: "Session", hidden: true, - run: () => { - dialog.clear() - moveFirstChild() - }, + run: () => unavailable("Child session discovery"), }, { title: "Go to parent session", @@ -1045,7 +738,7 @@ export function Session() { category: "Session", hidden: true, enabled: !!session()?.parentID, - run: childSessionHandler(() => { + run: () => { const parentID = session()?.parentID if (parentID) { navigate({ @@ -1054,7 +747,7 @@ export function Session() { }) } dialog.clear() - }), + }, }, { title: "Next child session", @@ -1062,10 +755,7 @@ export function Session() { category: "Session", hidden: true, enabled: !!session()?.parentID, - run: childSessionHandler(() => { - dialog.clear() - moveChild(1) - }), + run: () => unavailable("Sibling session navigation"), }, { title: "Previous child session", @@ -1073,10 +763,7 @@ export function Session() { category: "Session", hidden: true, enabled: !!session()?.parentID, - run: childSessionHandler(() => { - dialog.clear() - moveChild(-1) - }), + run: () => unavailable("Sibling session navigation"), }, ]) @@ -1109,36 +796,6 @@ export function Session() { bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, - enabled: foregroundTasks().length > 0, - priority: 1, - bindings: tuiConfig.keybinds.get("session.background"), - })) - - const revertInfo = createMemo(() => session()?.revert) - const revertMessageID = createMemo(() => revertInfo()?.messageID) - - const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? "")) - - const revertRevertedMessages = createMemo(() => { - const messageID = revertMessageID() - if (!messageID) return [] - return messages().filter((x) => x.id >= messageID && x.role === "user") - }) - - const revert = createMemo(() => { - const info = revertInfo() - if (!info) return - if (!info.messageID) return - return { - messageID: info.messageID, - reverted: revertRevertedMessages(), - diff: info.diff, - diffFiles: revertDiffFiles(), - } - }) - // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) @@ -1156,9 +813,9 @@ export function Session() { showTimestamps, showDetails, showGenericToolOutput, + groupExploration, diffWrapMode, - providers, - sync, + models, tui: tuiConfig, }} > @@ -1183,99 +840,12 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - - - {(message, index) => ( - - - {(function () { - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - keymap.dispatchCommand("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - + + {(row) => ( + data.session.message.get(route.sessionID, messageID)} + /> )} @@ -1283,13 +853,13 @@ export function Session() { 0}> 0}> @@ -1347,46 +917,268 @@ export function Session() { ) } -function UserMessage(props: { - message: UserMessage - parts: Part[] - onMouseUp: () => void - index: number - pending?: string +function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) { + return ( + + + + {(row) => ( + + {(message) => } + + )} + + + {(row) => } + + + {(row) => ( + + )} + + + {(row) => ( + + {(message) => ( + + + + )} + + )} + + + + ) +} + +function SessionMessageView(props: { message: SessionMessage }) { + return ( + + + + + + + {props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""} + + + + + + + + + + + + + ) +} + +function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) { + const message = createMemo(() => props.message(props.partRef.messageID)) + const part = createMemo(() => { + const item = message() + if (item?.type !== "assistant") return + return item.content.find((part) => part.id === props.partRef.partID) + }) + return ( + + {(item) => ( + + + + + + + + + + + + )} + + ) +} + +function SessionGroupView(props: { + refs: PartRef[] + pending: PartRef[] + completed: boolean + message: (messageID: string) => SessionMessage | undefined }) { + const { theme } = useTheme() + const ctx = use() + const renderer = useRenderer() + const [expanded, setExpanded] = createSignal(false) + const [hover, setHover] = createSignal(false) + const parts = (refs: PartRef[]) => + refs.flatMap((ref) => { + const message = props.message(ref.messageID) + if (message?.type !== "assistant") return [] + const part = message.content.find((part) => part.id === ref.partID) + if (part?.type !== "tool") return [] + return [part] + }) + const grouped = createMemo(() => parts(props.refs)) + const pending = createMemo(() => parts(props.pending)) + const label = createMemo(() => { + const counts = grouped().reduce>((result, part) => { + const tool = toolDisplay(part.name) + const name = tool === "grep" || tool === "glob" ? "search" : tool + result[name] = (result[name] ?? 0) + 1 + return result + }, {}) + const tools = Object.entries(counts).map(([name, count]) => + `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, + ) + return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}` + }) + return ( + 0 || pending().length > 0}> + {(part) => }} + > + 0}> + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + setExpanded((value) => !value) + }} + > + {label()} + + + 0}> + {(part) => } + + {(part) => } + + + ) +} + +function AssistantFooter(props: { message: SessionMessageAssistant }) { const ctx = use() const local = useLocal() - const text = createMemo(() => { - const texts = props.parts - .map((x) => { - if (x.type === "text" && !x.synthetic) { - return x.text - } - return null - }) - .filter(Boolean) - return texts.join("\n\n") - }) - const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) + const { theme } = useTheme() + const model = createMemo( + () => + ctx.models().find( + (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, + )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ) + const duration = createMemo(() => + props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, + ) + return ( + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + + ) +} + +function SessionSwitchMessageV2(props: { message: SessionMessage }) { + const { theme } = useTheme() + const text = () => { + if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}` + if (props.message.type === "model-switched") + return `Switched model to ${props.message.model.providerID}/${props.message.model.id}` + return "" + } + return {text()} +} + +function SessionNoticeMessageV2(props: { message: SessionMessage }) { + const { theme } = useTheme() + return ( + + {props.message.type === "system" || props.message.type === "synthetic" ? props.message.text : ""} + + ) +} + +function CompactionMessage() { + const { theme } = useTheme() + return ( + + ) +} + +function RevertMessage(props: { count: number }) { + const { theme } = useTheme() + const dialog = useDialog() + const renderer = useRenderer() + const toast = useToast() + const [hover, setHover] = createSignal(false) + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) + dialog.clear() + }} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {props.count} message{props.count === 1 ? "" : "s"} reverted + Redo is not implemented for V2 sessions yet + + + ) +} + +function UserMessage(props: { message: SessionMessageUser }) { + const ctx = use() + const local = useLocal() + const files = createMemo(() => props.message.files ?? []) const { theme } = useTheme() const [hover, setHover] = createSignal(false) - const queued = createMemo(() => props.pending && props.message.id > props.pending) - const color = createMemo(() => local.agent.color(props.message.agent)) - const queuedFg = createMemo(() => selectedForeground(theme, color())) - const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) - - const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) + const color = createMemo(() => local.agent.color(useData().session.get(ctx.sessionID)?.agent ?? "build")) + const dialog = useDialog() + const renderer = useRenderer() return ( - <> - + alwaysSeparate.add(el)} border={["left"]} borderColor={color()} customBorderChars={SplitBorder.customBorderChars} - marginTop={props.index === 0 ? 0 : 1} > { @@ -1395,16 +1187,19 @@ function UserMessage(props: { onMouseOut={() => { setHover(false) }} - onMouseUp={props.onMouseUp} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ) + }} paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()} + {props.message.text} - + {(file) => { const directory = file.mime === "application/x-directory" @@ -1413,52 +1208,34 @@ function UserMessage(props: { {directory ? " Directory " : " File "} - {file.filename} + {file.name ?? file.uri} ) }} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - - } - > + - QUEUED + {Locale.todayTimeOrDateTime(props.message.time.created)} - - - - - + ) } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { +function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) { const ctx = use() const local = useLocal() const { theme } = useTheme() - const sync = useSync() - const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) - const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) + const model = createMemo( + () => + ctx.models().find( + (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, + )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ) const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) @@ -1467,92 +1244,97 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const duration = createMemo(() => { if (!final()) return 0 if (!props.message.time.completed) return 0 - const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) - if (!user || !user.time) return 0 - return props.message.time.completed - user.time.created + return props.message.time.completed - props.message.time.created }) - const childShortcut = useCommandShortcut("session.child.first") - const backgroundShortcut = useCommandShortcut("session.background") + const exploration = createMemo(() => { + const grouped = new Map< + string, + { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean } + >() + if (!ctx.groupExploration()) return grouped + const runs = props.message.content + .map((part) => + part.type === "tool" && + ["read", "glob", "grep"].includes(toolDisplay(part.name)) && + part.state.status !== "pending" + ? part + : undefined, + ) + .reduce( + (runs, part) => { + if (part) runs[runs.length - 1].push(part) + if (!part && runs[runs.length - 1].length) runs.push([]) + return runs + }, + [[]], + ) + .filter((run) => run.length > 0) + for (const run of runs) { + const summary = { + parts: run, + active: false, + } + run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 })) + } + return grouped + }) return ( <> - - {(part, index) => { - const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) - return ( - - + {(content, index) => ( + + + - - ) - }} + + + + + + + } + > + {(summary) => } + + + + + )} - x.type === "tool" && x.tool === "task")}> - - - {childShortcut()} - view subagents - - x.type === "tool" && - x.tool === "task" && - x.state.status === "running" && - x.state.metadata?.background !== true, - ) - } - > - · - {backgroundShortcut()} - background - - - - - + alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} paddingLeft={2} - marginTop={1} backgroundColor={theme.backgroundPanel} customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} > - {props.message.error?.data.message} + {errorMessage(props.message.error)} - - alwaysSeparate.add(el)} paddingLeft={3}> - - - ▣{" "} - {" "} - {Locale.titlecase(props.message.mode)} + + + + + {Locale.titlecase(props.message.agent)} + · {model()} · {Locale.duration(duration())} - - · interrupted - @@ -1561,15 +1343,47 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) } -const PART_MAPPING = { - text: TextPart, - tool: ToolPart, - reasoning: ReasoningPart, +function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) { + const { theme } = useTheme() + const pathFormatter = usePathFormatter() + const label = (part: SessionMessageAssistantTool) => { + const input = typeof part.state.input === "string" ? {} : part.state.input + const tool = toolDisplay(part.name) + if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}` + if (tool === "glob") return `Glob "${stringValue(input.pattern)}"` + return `Grep "${stringValue(input.pattern)}"` + } + return ( + + + {props.active ? "Exploring" : "Explored"} + + + {(part, index) => ( + + + {index() === props.parts.length - 1 ? "└" : "├"} {label(part)} + + + )} + + + ) } const INLINE_TOOL_ICON_WIDTH = 2 -function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { +function ReasoningPart(props: { + last: boolean + part: SessionMessageAssistantReasoning + message: SessionMessageAssistant +}) { const { theme } = useTheme() const ctx = use() // Collapsed by default in hide mode: a single line throughout, so the @@ -1580,13 +1394,12 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass // OpenRouter encrypts some reasoning blocks; drop the placeholder. return props.part.text.replace("[REDACTED]", "").trim() }) - // Reasoning is finalized when the server sets `time.end` (see processor.ts). - // Flips independently of the parent message completing. - const isDone = createMemo(() => props.part.time.end !== undefined) + const isDone = createMemo(() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") const duration = createMemo(() => { - const end = props.part.time.end - return end === undefined ? 0 : Math.max(0, end - props.part.time.start) + const end = props.part.time?.completed ?? props.message.time.completed + const start = props.part.time?.created ?? props.message.time.created + return end === undefined ? 0 : Math.max(0, end - start) }) const summary = createMemo(() => reasoningSummary(content())) const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) @@ -1599,9 +1412,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass return ( alwaysSeparate.add(el)} paddingLeft={3} - marginTop={1} flexDirection="column" flexShrink={0} > @@ -1676,12 +1487,12 @@ function ReasoningHeader(props: { ) } -function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { +function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { const ctx = use() const { theme, syntax } = useTheme() return ( - alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> + toolDisplay(props.part.tool)) + const display = createMemo(() => toolDisplay(props.part.name)) // Hide tool if showDetails is false and tool completed successfully const shouldHide = createMemo(() => { @@ -1712,16 +1523,19 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess const toolprops = { get metadata() { - return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) + return props.part.state.status === "pending" ? {} : props.part.state.structured }, get input() { - return props.part.state.input ?? {} + return typeof props.part.state.input === "string" ? {} : props.part.state.input }, get output() { - return props.part.state.status === "completed" ? props.part.state.output : undefined + if (props.part.state.status === "pending") return undefined + return props.part.state.content + .flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri])) + .join("\n") }, get tool() { - return props.part.tool + return props.part.name }, get part() { return props.part @@ -1783,7 +1597,7 @@ type ToolProps = { metadata: Record tool: string output?: string - part: ToolPart + part: SessionMessageAssistantTool } function GenericTool(props: ToolProps) { const { theme } = useTheme() @@ -1831,25 +1645,23 @@ function InlineTool(props: { pending: string failure?: string spinner?: boolean - separate?: boolean children: JSX.Element - part: ToolPart + part: SessionMessageAssistantTool onClick?: () => void }) { const { theme } = useTheme() const ctx = use() - const sync = useSync() + const data = useData() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const [errorExpanded, setErrorExpanded] = createSignal(false) const permission = createMemo(() => { - const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID - if (!callID) return false - return callID === props.part.callID + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === props.part.id }) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) const denied = createMemo( () => @@ -1884,7 +1696,6 @@ function InlineTool(props: { pending={props.pending} failure={props.failure} spinner={props.spinner} - separate={props.separate} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1914,7 +1725,6 @@ export function InlineToolRow(props: { pending: string failure?: string spinner?: boolean - separate?: boolean children: JSX.Element onMouseOver?: () => void onMouseOut?: () => void @@ -1926,15 +1736,6 @@ export function InlineToolRow(props: { onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp} - ref={(el: BoxRenderable) => { - if (props.separate) alwaysSeparate.add(el) - setPreLayoutSiblingMargin(el, (previous) => { - return props.separate || - (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous))) - ? 1 - : 0 - }) - }} > @@ -1983,23 +1784,28 @@ export function InlineToolRow(props: { function BlockTool(props: { title?: string - children: JSX.Element + children?: JSX.Element onClick?: () => void - part?: ToolPart + part?: SessionMessageAssistantTool spinner?: boolean }) { const { theme } = useTheme() + const ctx = use() + const data = useData() const renderer = useRenderer() const [hover, setHover] = createSignal(false) - const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + const permission = createMemo(() => { + if (!props.part) return false + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === props.part.id + }) return ( alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} paddingLeft={2} - marginTop={1} gap={1} backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel} customBorderChars={SplitBorder.customBorderChars} @@ -2016,12 +1822,12 @@ function BlockTool(props: { + {title()} } > - {title().replace(/^# /, "")} + {title().replace(/^# /, "")} )} @@ -2035,60 +1841,64 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const pathFormatter = usePathFormatter() const ctx = use() + const data = useData() + const permission = createMemo(() => { + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === props.part.id + }) + const color = createMemo(() => (permission() ? theme.warning : theme.text)) const isRunning = createMemo(() => props.part.state.status === "running") - const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? "")) + const command = createMemo(() => stringValue(props.input.command)) + const output = createMemo(() => { + if (props.part.state.status === "pending") return "" + const content = props.part.state.content[0] + return stripAnsi(content?.type === "text" ? content.text.trim() : "") + }) const [expanded, setExpanded] = createSignal(false) const maxLines = 10 const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) - const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) + const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : "")) + const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n")) + const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !collapsed().overflow) return output() + if (expanded() || !collapsed().overflow) return content() return collapsed().output }) - const workdirDisplay = createMemo(() => { - const workdir = stringValue(props.input.workdir) - if (!workdir || workdir === ".") return undefined - const formatted = pathFormatter.format(workdir) - if (formatted === ".") return undefined - return formatted - }) - - const title = createMemo(() => { - const wd = workdirDisplay() - if (!wd) return - return `# Running in ${wd}` - }) - return ( - - - setExpanded((prev) => !prev) : undefined} + setExpanded((prev) => !prev) : undefined}> + + Writing command... + ) : ( + Writing command... + ) + } > - - $ {stringValue(props.input.command)}}> - {stringValue(props.input.command)} - - - {limited()} - - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - - - - {stringValue(props.input.command)} - - - + + {limited().slice(0, input().length)} + {limited().slice(input().length)} + + } + > + + {limited().slice(0, input().length)} + {limited().slice(input().length)} + + + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + ) } @@ -2102,27 +1912,27 @@ function Write(props: ToolProps) { return ( - + - + - Write {pathFormatter.format(stringValue(props.input.filePath))} + Write {pathFormatter.format(stringValue(props.input.path))} @@ -2148,7 +1958,6 @@ function Read(props: ToolProps) { const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] - if (props.part.state.time.compacted) return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") @@ -2158,11 +1967,11 @@ function Read(props: ToolProps) { - Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} + Read {pathFormatter.format(stringValue(props.input.path))} {(filepath) => ( @@ -2208,99 +2017,27 @@ function WebSearch(props: ToolProps) { } function Task(props: ToolProps) { - const { theme } = useTheme() const { navigate } = useRoute() - const sync = useSync() - const dialog = useDialog() - - onMount(() => { - const sessionID = stringValue(props.metadata.sessionId) - if (sessionID && !sync.data.message[sessionID]?.length) void sync.session.sync(sessionID) - }) - - const sessionID = createMemo(() => stringValue(props.metadata.sessionId)) - const messages = createMemo(() => sync.data.message[sessionID() ?? ""] ?? []) - - const tools = createMemo(() => { - return messages().flatMap((msg) => - (sync.data.part[msg.id] ?? []) - .filter((part): part is ToolPart => part.type === "tool") - .map((part) => ({ tool: part.tool, state: part.state })), - ) - }) - - const current = createMemo(() => - tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title), - ) - - const status = createMemo(() => sync.data.session_status[sessionID() ?? ""]) - const isRunning = createMemo(() => { - const value = status() - return ( - props.part.state.status === "running" || - (props.metadata.background === true && value !== undefined && value.type !== "idle") - ) - }) - const retry = createMemo(() => { - const value = status() - if (value?.type !== "retry") return - return value - }) - - const duration = createMemo(() => { - const first = messages().find((x) => x.role === "user")?.time.created - const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed - if (!first || !assistant) return 0 - return assistant - first - }) - - const content = createMemo(() => { - const description = stringValue(props.input.description) - if (!description) return "" - let content = [ - formatSubagentTitle( - Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"), - description, - props.metadata.background === true, - ), - ] - - const retrying = retry() - if (isRunning() && retrying) { - content.push(`↳ ${formatSubagentRetry(retrying.attempt, Locale.truncate(retrying.message, 80))}`) - } else if (isRunning() && tools().length > 0) { - if (current()) { - const state = current()!.state - const title = state.status === "running" || state.status === "completed" ? state.title : undefined - content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`) - } else content.push(`↳ ${formatSubagentToolcalls(tools().length)}`) - } - - if (!isRunning() && props.part.state.status === "completed") { - content.push(`↳ ${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`) - } - - return content.join("\n") - }) + const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId)) + const description = createMemo(() => stringValue(props.input.description)) return ( { - if (sessionID()) { - navigate({ type: "session", sessionID: sessionID()! }) - } - const status = retry() - if (status) void DialogAlert.show(dialog, "Retry Error", status.message) + const id = sessionID() + if (id) navigate({ type: "session", sessionID: id }) }} > - {content()} + {formatSubagentTitle( + Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"), + description() ?? "Task", + props.metadata.background === true, + )} ) } @@ -2334,42 +2071,49 @@ function Edit(props: ToolProps) { return ctx.width > 120 ? "split" : "unified" }) - const ft = createMemo(() => filetype(stringValue(props.input.filePath))) - - const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "") + const file = createMemo(() => parseApplyPatchFiles(props.metadata.files)[0]) + const path = createMemo(() => file()?.relativePath ?? stringValue(props.input.path)) return ( - - - - - - - + + {(item) => ( + + + + + + + )} - - Edit {pathFormatter.format(stringValue(props.input.filePath))} {input({ replaceAll: props.input.replaceAll })} - + ) @@ -2379,73 +2123,89 @@ function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() const pathFormatter = usePathFormatter() - const files = createMemo(() => parseApplyPatchFiles(props.metadata.files)) - + const targets = createMemo(() => { + const patch = stringValue(props.input.patchText) + if (!patch) return [] + return [...patch.matchAll(/\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)/g)].map((match) => match[1].trim()) + }) + const applied = createMemo(() => { + const applied = props.metadata.applied + if (!Array.isArray(applied)) return [] + return applied.flatMap((value) => { + const item = recordValue(value) + const type = stringValue(item?.type) + const resource = stringValue(item?.resource) + return type && resource ? [{ type, resource }] : [] + }) + }) const view = createMemo(() => { - const diffStyle = ctx.tui.diff_style - if (diffStyle === "stacked") return "unified" + if (ctx.tui.diff_style === "stacked") return "unified" return ctx.width > 120 ? "split" : "unified" }) - function Diff(p: { diff: string; filePath: string }) { - return ( - - - - ) - } - - function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { - if (file.type === "delete") return "# Deleted " + file.relativePath - if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath - return "← Patched " + file.relativePath - } - return ( 0}> - - {(file) => ( - - - -{file.deletions} line{file.deletions !== 1 ? "s" : ""} - - } + + + {(file) => ( + - - - - - )} - + + + + + )} + + + + 0}> + + + {(file) => ( + + {file.resource} + + )} + + - - Patch - + ) @@ -2589,18 +2349,53 @@ function recordValue(value: unknown): Record | undefined { return value as Record } +function formatSessionTranscript( + session: SessionV2Info, + messages: SessionMessage[], + thinking: boolean, + toolDetails: boolean, +) { + const body = messages.flatMap((message) => { + if (message.type === "user") return [`## User\n\n${message.text}`] + if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output}\n\`\`\``] + if (message.type !== "assistant") return [] + const content = message.content.flatMap((item) => { + if (item.type === "text") return [item.text] + if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : [] + if (!toolDetails) return [`**Tool: ${item.name}**`] + const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2) + const output = + item.state.status === "error" + ? item.state.error.message + : item.state.status === "pending" + ? "" + : item.state.content + .flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri])) + .join("\n") + return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] + }) + return [`## Assistant\n\n${content.join("\n\n")}`] + }) + return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n` +} + export function parseApplyPatchFiles(value: unknown) { if (!Array.isArray(value)) return [] return value.flatMap((item) => { const file = recordValue(item) if (!file) return [] - const type = stringValue(file.type) - const relativePath = stringValue(file.relativePath) - const filePath = stringValue(file.filePath) + const status = stringValue(file.status) + const type = + stringValue(file.type) ?? + (status === "added" ? "add" : status === "deleted" ? "delete" : status === "modified" ? "update" : undefined) + const relativePath = stringValue(file.file) ?? stringValue(file.relativePath) + const filePath = stringValue(file.filePath) ?? relativePath const patch = stringValue(file.patch) + const additions = numberValue(file.additions) const deletions = numberValue(file.deletions) - if (!type || !relativePath || !filePath || patch === undefined || deletions === undefined) return [] - return [{ type, relativePath, filePath, patch, deletions, movePath: stringValue(file.movePath) }] + if (!type || !relativePath || !filePath || patch === undefined || additions === undefined || deletions === undefined) + return [] + return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }] }) } diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 766ed3c116..159691a6d1 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -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 }) { - - No diff provided - + + No diff provided + + } + > + {(patch) => ( + + + + )} + ) @@ -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={ - - + + This will allow the following patterns until OpenCode is restarted - + {(pattern) => ( {"- "} @@ -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? { - 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? {(() => { 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: , + body: , } } 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: ( @@ -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? {"△"} Permission required - - - {current.icon} - - {current.title} - + + + + {current.icon} + + {current.title} + + ) @@ -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(), }) }} /> diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a..ded2c6db4f 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -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(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 } diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts new file mode 100644 index 0000000000..e10a84f9d7 --- /dev/null +++ b/packages/tui/src/routes/session/rows.ts @@ -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) { + const data = useData() + const [rows, setRows] = createStore([]) + + 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((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, + ) + }) +} diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 0c5d2b3139..5639f6451f 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -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} > @@ -60,11 +59,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {props.sessionID} - + } + fallback={ + + } > {(item) => ( - - {session()!.share!.url} - diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 0aadb4f43c..71b3a161e5 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -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() { - {subagentInfo().label} + {subagentInfo()} - 0}> - - ({subagentInfo().index} of {subagentInfo().total}) - - {(item) => ( @@ -95,10 +83,10 @@ export function SubagentFooter() { 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} > Parent {parentShortcut()} diff --git a/packages/tui/src/runtime.tsx b/packages/tui/src/runtime.tsx index 6f519cbe82..f503944f2e 100644 --- a/packages/tui/src/runtime.tsx +++ b/packages/tui/src/runtime.tsx @@ -5,5 +5,6 @@ export function abbreviateHome(input: string, home: string) { const relative = path.relative(home, input) if (relative === "") return "~" if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return input - return "~" + path.sep + relative + // Normalize to forward slashes so abbreviated display paths are identical across platforms. + return "~/" + relative.split(path.sep).join("/") } diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index d627ea2970..f518fb2950 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -83,7 +83,7 @@ export function DialogPrompt(props: DialogPromptProps) { - {props.description} + {props.description?.()}