Compare commits

...
97 changed files with 3576 additions and 3547 deletions
+4 -2
View File
@@ -6,6 +6,7 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -122,7 +123,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 +222,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 +448,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
-5
View File
@@ -74,11 +74,6 @@ 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 }})
strategy:
+3 -1
View File
@@ -93,7 +93,7 @@
"name": "@opencode-ai/cli",
"version": "1.17.11",
"bin": {
"lildax": "./bin/lildax.cjs",
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@effect/platform-node": "catalog:",
@@ -316,6 +316,7 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
@@ -934,6 +935,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
+82
View File
@@ -0,0 +1,82 @@
# V2 CLI and TUI development guide
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Interactive debugging
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
@@ -31,11 +31,11 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".lildax")
const cached = path.join(scriptDir, ".opencode2")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "lildax.exe" : "lildax"
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
function supportsAvx2() {
if (arch !== "x64") return false
@@ -121,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error(
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") +
" package",
)
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"license": "MIT",
"bin": {
"lildax": "./bin/lildax.cjs"
"opencode2": "./bin/opencode2.cjs"
},
"files": [
"bin"
+1 -1
View File
@@ -10,7 +10,7 @@ import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax"
const binary = "opencode2"
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
+7 -6
View File
@@ -25,14 +25,15 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
const name = "opencode-ai"
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${name}/package.json`).write(
JSON.stringify(
{
name: pkg.name,
bin: { lildax: "./bin/lildax" },
name,
bin: { opencode2: "./bin/opencode2" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
@@ -50,4 +51,4 @@ await Promise.all(
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`./dist/${pkg.name}`, pkg.name, version)
await publish(`./dist/${name}`, name, version)
+11
View File
@@ -5,6 +5,16 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
},
commands: [
Spec.make("api", {
description: "Make a request to the running server",
@@ -46,6 +56,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
}),
],
@@ -1,12 +1,15 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(Commands, () =>
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
}),
+22 -5
View File
@@ -6,6 +6,8 @@ import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
@@ -16,11 +18,22 @@ 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)}`)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
return yield* Effect.never
}),
}).pipe(Effect.annotateLogs({ role: "server" })),
)
}),
)
@@ -35,11 +48,15 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
}
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
}
@@ -8,6 +8,6 @@ export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
process.stdout.write((url ? url : "stopped") + EOL)
}),
)
+4 -3
View File
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { Scope } from "effect"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +11,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
+12
View File
@@ -2,10 +2,19 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -25,8 +34,11 @@ const Handlers = Runtime.handlers(Commands, {
})
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain,
)
+22 -10
View File
@@ -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
}
@@ -38,21 +42,29 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const 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]
+38
View File
@@ -0,0 +1,38 @@
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,
stdin: "ignore",
stderr: "ignore",
killSignal: "SIGKILL",
})
}
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 }) }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Standalone from "./standalone"
+29 -27
View File
@@ -2,35 +2,37 @@ 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"] }) {
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 }),
args: {},
config,
pluginHost: {
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
},
async dispose() {
disposeSlots?.()
},
},
})
}).pipe(Effect.provide(Global.defaultLayer))
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
+2
View File
@@ -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"
+1
View File
@@ -102,6 +102,7 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
+21 -3
View File
@@ -1,6 +1,7 @@
export * as Integration from "./integration"
import {
Cache,
Cause,
Clock,
Context,
@@ -310,6 +311,25 @@ export const locationLayer = Layer.effect(
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
const refreshes = yield* Cache.make<Credential.ID, Credential.Value | undefined, AuthorizationError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
if (!credential || credential.value.type === "key") return credential?.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credentialID, { value })
return value
}),
})
const close = (attemptScope: Scope.Closeable) =>
Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
@@ -396,9 +416,7 @@ export const locationLayer = Layer.effect(
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
return yield* Cache.get(refreshes, credential.id)
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
+5 -1
View File
@@ -1,3 +1,7 @@
export * as PublicEventManifest from "./public-event-manifest"
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
+7 -1
View File
@@ -390,7 +390,13 @@ export const layer = Layer.unwrap(
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
@@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp },
}),
),
)
@@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
+1 -1
View File
@@ -132,7 +132,7 @@ export const fromCatalogModel = (
credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.metadata === undefined
credential?.type !== "key" || credential.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata)
+45 -11
View File
@@ -1,6 +1,8 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export const Output = Schema.Struct({
applied: Schema.Array(Applied),
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
@@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly content: string
readonly before: string
readonly after: string
})
export const layer = Layer.effectDiscard(
@@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
})
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target })
prepared.push({ ...hunk, target, before, after: "" })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
const update = Patch.derive(hunk.path, hunk.chunks, original)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile)
yield* Effect.forEach(
prepared,
(change) =>
@@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied }
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
@@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}
+22 -6
View File
@@ -7,6 +7,8 @@
export * as EditTool from "./edit"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -30,10 +32,7 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
replacements: Schema.Number,
})
export type Output = typeof Output.Type
@@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.resource}`,
`Edited file successfully: ${output.files[0]?.file}`,
`Replacements: ${output.replacements}`,
"```diff",
...previewLines(oldString, "-"),
@@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
input.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const counts = diffLines(source.text, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
@@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Output
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
})
},
}),
@@ -42,6 +42,15 @@ const openai: Lowerer = {
},
request(options) {
const result = snake(options)
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
result.reasoning = {
...(isRecord(result.reasoning) ? result.reasoning : {}),
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
}
delete result.reasoning_effort
delete result.reasoning_summary
}
if (options.textVerbosity !== undefined) {
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
delete result.text_verbosity
+2 -2
View File
@@ -601,9 +601,9 @@ describe("Config", () => {
models: {
model: {
request: {
body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
},
variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
},
},
})
@@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => {
expect(
lowerer.request({
reasoningEffort: "high",
reasoningSummary: "auto",
reasoning: { encryptedContent: true },
textVerbosity: "low",
text: { outputFormat: "plain" },
nestedValue: { camelCase: true },
}),
).toEqual({
reasoning_effort: "high",
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
text: { output_format: "plain", verbosity: "low" },
nested_value: { camel_case: true },
})
@@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
body: { trace: true },
settings: { resourceName: "resource" },
})
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
reasoning_effort: "high",
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
reasoning: { effort: "high", summary: "auto" },
text: { verbosity: "low" },
})
})
+113 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -346,4 +346,116 @@ describe("Integration", () => {
}),
)
})
it.effect("shares concurrent OAuth credential refreshes", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let refreshes = 0
const value = Credential.OAuth.make({
type: "oauth",
methodID,
access: "refreshed",
refresh: "refresh-2",
expires: Duration.toMillis(Duration.hours(1)),
})
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => Effect.die("unexpected authorization"),
refresh: () =>
Effect.sync(() => refreshes++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
Effect.as(value),
),
}),
)
const credential = yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh-1",
expires: 0,
}),
})
const connection = { type: "credential" as const, id: credential.id, label: credential.label }
const first = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(refreshes).toBe(1)
yield* Deferred.succeed(release, undefined)
expect(yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: "unbounded" })).toEqual([
value,
value,
])
expect(refreshes).toBe(1)
expect((yield* credentials.get(credential.id))?.value).toEqual(value)
}),
)
it.effect("shares concurrent refresh failures and retries later", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const failure = new Error("refresh failed")
let refreshes = 0
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => Effect.die("unexpected authorization"),
refresh: () =>
Effect.sync(() => refreshes++).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.fail(failure)),
),
}),
)
const credential = yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
}),
})
const connection = { type: "credential" as const, id: credential.id, label: credential.label }
const first = yield* integrations.connection.resolve(connection).pipe(Effect.flip, Effect.forkChild)
yield* Deferred.await(started)
const second = yield* integrations.connection.resolve(connection).pipe(Effect.flip, Effect.forkChild)
yield* Effect.yieldNow
expect(refreshes).toBe(1)
yield* Deferred.succeed(release, undefined)
const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)], { concurrency: "unbounded" })
expect(results).toEqual([
new Integration.AuthorizationError({ cause: failure }),
new Integration.AuthorizationError({ cause: failure }),
])
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect(refreshes).toBe(2)
}),
)
})
+20 -2
View File
@@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
}),
)
it.effect("persists repeated switches as distinct durable Session events", () =>
it.effect("ignores a model switch when the selected model is unchanged", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
@@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(3)
).toHaveLength(2)
expect(yield* session.get(created.id)).toMatchObject({ model })
}),
)
it.effect("treats an omitted variant as the default variant", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const created = yield* session.create({ location, model })
yield* session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
})
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
@@ -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(
@@ -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: ["*"] },
+9 -4
View File
@@ -125,11 +125,16 @@ describe("EditTool", () => {
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
})
expect(settled.output?.structured).toEqual({
operation: "write",
target: yield* Effect.promise(() => fs.realpath(target)),
resource: "hello.txt",
existed: true,
replacements: 1,
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
+2 -3
View File
@@ -3,6 +3,7 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -132,7 +133,7 @@ export const AttachCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: args.url,
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
@@ -140,8 +141,6 @@ export const AttachCommand = cmd({
sessionID: args.session,
fork: args.fork,
},
directory,
headers,
}),
)
},
@@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
() => (
<box width="100%" height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: input.theme.block.highlight }}> </span>
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
{" "}
@@ -10,7 +10,7 @@ export function turnSummaryCommit(input: {
}): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final",
source: "system",
summary: {
+4 -57
View File
@@ -8,8 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
@@ -18,36 +17,6 @@ declare global {
const OPENCODE_WORKER_PATH: string
}
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
},
}
}
async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url)
@@ -211,32 +180,13 @@ export const TuiThreadCommand = cmd({
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
}
const url = (await client.call("server", network)).url
try {
await validateSession({
url: transport.url,
url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
@@ -254,7 +204,7 @@ export const TuiThreadCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: transport.url,
client: createOpencodeClient({ baseUrl: url, directory: cwd }),
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -262,9 +212,6 @@ export const TuiThreadCommand = cmd({
},
config,
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
-26
View File
@@ -3,8 +3,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
@@ -20,33 +18,9 @@ const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers,
body: input.body,
})
const response = await Server.Default().app.fetch(request)
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
},
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
+2 -5
View File
@@ -1,10 +1,7 @@
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return createBuiltinPlugins({
experimentalEventSystem: flags.experimentalEventSystem,
})
export function internalTuiPlugins(): InternalTuiPlugin[] {
return createBuiltinPlugins()
}
+1 -1
View File
@@ -1089,7 +1089,7 @@ async function load(input: {
if (Flag.OPENCODE_PURE && pluginOrigins.length) {
}
for (const item of internalTuiPlugins(flags)) {
for (const item of internalTuiPlugins()) {
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
@@ -119,7 +119,7 @@ test("turn summary starts at the left edge", async () => {
const commits = claim(out.renderer)
try {
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
} finally {
destroy(commits)
}
@@ -279,7 +279,7 @@ describe("run session replay", () => {
}),
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.8s",
text: "Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
@@ -314,7 +314,7 @@ describe("run session replay", () => {
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "Build · Little Frank · 2.8s",
text: "Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
@@ -346,7 +346,7 @@ describe("run session replay", () => {
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.0s",
text: "Build · gpt-5 · 2.0s",
messageID: "msg-step-2",
}),
])
-82
View File
@@ -1,82 +0,0 @@
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/opencode`
export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
switch (url.pathname) {
case "/agent":
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
case "/session/status":
return json({})
case "/config/providers":
return json({ providers: {}, default: {} })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree, directory })
case "/project/current":
return json({ id: "proj_test" })
case "/provider":
return json({ all: [], default: {}, connected: [] })
case "/session":
return json([])
case "/vcs":
return json({ branch: "main" })
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
return { fetch, session }
}
+4
View File
@@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+4
View File
@@ -3926,6 +3926,10 @@ export type SessionMessageAssistantReasoning = {
id: string
text: string
providerMetadata?: LlmProviderMetadata
time?: {
created: number
completed?: number
}
}
export type SessionMessageToolStatePending = {
+11 -2
View File
@@ -1,16 +1,25 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Effect, Stream } from "effect"
import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
import { Effect, Schema, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { Api } from "../api"
function eventData(data: unknown): Sse.Event {
const event = data as EventV2.Payload
const definition = PublicEventManifest.Latest.get(event.type)
const encoded = definition
? {
...event,
data: Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
}
: event
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
data: JSON.stringify(encoded),
}
}
+1
View File
@@ -54,6 +54,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
+21 -35
View File
@@ -21,11 +21,10 @@ import {
onCleanup,
batch,
Show,
on,
} from "solid-js"
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
import { DialogProvider as DialogProviderList } from "./component/dialog-provider"
import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component"
import { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project"
@@ -33,8 +32,9 @@ import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
@@ -76,7 +76,7 @@ import {
useOpencodeKeymap,
} from "./keymap"
import type { EventSource } from "./context/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio"
@@ -134,14 +134,10 @@ const appBindingCommands = [
] as const
export type TuiInput = {
url: string
client: OpencodeClient
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
}
@@ -268,7 +264,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
@@ -289,13 +289,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SDKProvider client={input.client}>
<ProjectProvider>
<SyncProvider>
<DataProvider>
@@ -370,6 +364,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
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 +384,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event,
sdk,
sync,
data,
theme: themeState,
toast,
renderer,
@@ -494,9 +490,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
createEffect(() => {
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
if (continued || sync.status === "loading" || !args.continue) return
const match = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.find((x) => x.parentID === undefined)?.id
const match = data.session.list().find((session) => !session.parentID)?.id
if (match) {
continued = true
if (args.fork) {
@@ -529,17 +523,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
})
})
createEffect(
on(
() => sync.status === "complete" && sync.data.provider.length === 0,
(isEmpty, wasEmpty) => {
// only trigger when we transition into an empty-provider state
if (!isEmpty || wasEmpty) return
dialog.replace(() => <DialogProviderList />)
},
),
)
const connected = useConnected()
const currentWorktreeWorkspace = createMemo(() => {
const workspaceID = project.workspace.current()
@@ -563,7 +546,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
name: "session.list",
title: "Switch session",
category: "Session",
suggested: sync.data.session.length > 0,
suggested: data.session.list().length > 0,
slashName: "sessions",
slashAliases: ["resume", "continue"],
run: () => {
@@ -729,13 +712,13 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
{
name: "provider.connect",
title: "Connect provider",
title: "Connect integration",
suggested: !connected(),
slashName: "connect",
run: () => {
dialog.replace(() => <DialogProviderList />)
dialog.replace(() => <DialogIntegration />)
},
category: "Provider",
category: "Integration",
},
...(sync.data.console_state.switchableOrgCount > 1
? [
@@ -1102,6 +1085,9 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
<Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} />
</Show>
<Show when={data.connection.status() === "reconnecting"}>
<Reconnecting attempt={data.connection.attempt()} error={data.connection.error()} />
</Show>
</box>
)
}
+4 -4
View File
@@ -10,9 +10,9 @@ export function DialogAgent() {
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
value: item.id,
title: item.id,
description: item.description,
}
}),
)
@@ -20,7 +20,7 @@ export function DialogAgent() {
return (
<DialogSelect
title="Select agent"
current={local.agent.current()?.name}
current={local.agent.current()?.id}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)
@@ -0,0 +1,463 @@
import { TextAttributes } from "@opentui/core"
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { Link } from "../ui/link"
import { useToast } from "../ui/toast"
const INTEGRATION_PRIORITY: Record<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
}
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted(
(a, b) =>
(INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
}
export function connectMethods(integration: IntegrationInfo): ConnectMethod[] {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export function credentialConnections(integration: IntegrationInfo) {
return integration.connections.filter(
(connection): connection is Extract<ConnectionInfo, { type: "credential" }> => connection.type === "credential",
)
}
export function connectionSummary(integration: IntegrationInfo) {
return integration.connections
.map((connection) => (connection.type === "credential" ? connection.label : `$${connection.name}`))
.join(", ")
}
export function DialogIntegration() {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
const methods = connectMethods(integration)
const connected = integration.connections.length > 0
return {
title: integration.name,
value: integration.id,
description: methods.length ? undefined : "Environment only",
footer: connectionSummary(integration) || undefined,
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog)
: selectMethod(integration, methods, dialog),
}
}),
)
return (
<DialogSelect
title="Connect a service"
options={options()}
emptyView={<text fg={theme.textMuted}>No integrations available</text>}
/>
)
}
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
dialog.replace(() => {
const data = useData()
const sdk = useSDK()
const toast = useToast()
return (
<DialogSelect
title={integration.name}
options={[
...(methods.length
? [
{
title: "Add connection",
value: "add",
onSelect: () => selectMethod(integration, methods, dialog),
},
]
: []),
...credentialConnections(integration).map((connection) => ({
title: `Disconnect ${connection.label}`,
value: connection.id,
onSelect: () => {
void sdk.client.v2.credential
.remove(
{ credentialID: connection.id, location: location(data) },
{ throwOnError: true },
)
.then(() => disconnected(integration.name, data, dialog, toast))
.catch(toast.error)
},
})),
]}
/>
)
})
}
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
onSelect: () => openMethod(integration, method, dialog),
}))}
/>
))
}
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
dialog: ReturnType<typeof useDialog>,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} />)
return
}
void beginOAuth(integration, method, dialog)
}
function KeyMethod(props: { integration: IntegrationInfo; method: Extract<ConnectMethod, { type: "key" }> }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
title={props.method.label ?? `Connect ${props.integration.name}`}
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.client.v2.integration.connect
.key(
{
integrationID: props.integration.id,
location: location(data),
key,
},
{ throwOnError: true },
)
.then(() => connected(props.integration.name, data, dialog, toast))
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
)}
/>
)
}
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
dialog: ReturnType<typeof useDialog>,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => <OAuthStarting integration={integration} method={method} inputs={inputs} />)
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
inputs: Record<string, string>
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
onMount(() => {
void sdk.client.v2.integration.connect
.oauth(
{
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
inputs: props.inputs,
},
{ throwOnError: true },
)
.then((result) => {
if (result.data.data.mode === "code") {
dialog.replace(() => (
<OAuthCode integration={props.integration} title={props.method.label} attempt={result.data.data} />
))
return
}
dialog.replace(() => (
<OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data.data} />
))
})
.catch((cause) => {
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
})
return <OAuthView title={props.method.label} message="Starting authorization..." />
}
function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const clipboard = useClipboard()
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
useBindings(() => ({
bindings: [
{
key: "c",
desc: "Copy authorization details",
group: "Dialog",
cmd: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
const poll = () => {
void sdk.client.v2.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true })
.then((result) => {
const status = result.data.data
if (status.status === "pending") {
timer = setTimeout(poll, 500)
return
}
settled = true
if (status.status === "complete") {
void connected(props.integration.name, data, dialog, toast)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
dialog.clear()
})
.catch((cause) => {
settled = true
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
}
onMount(poll)
onCleanup(() => {
if (timer) clearTimeout(timer)
if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
<OAuthView
title={props.title}
url={props.attempt.url}
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
/>
)
}
function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
let settled = false
onCleanup(() => {
if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.client.v2.integration.attempt
.complete(
{ attemptID: props.attempt.attemptID, location: location(data), code },
{ throwOnError: true },
)
.then(() => {
settled = true
return connected(props.integration.name, data, dialog, toast)
})
.catch((cause) => setError(message(cause)))
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.primary} />
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
</box>
)}
/>
)
}
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
}) {
const dialog = useDialog()
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={theme.primary} />
<Show when={props.instructions}>{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}</Show>
</box>
)}
</Show>
<text fg={theme.textMuted}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</Show>
</box>
)
}
async function promptInputs(
dialog: ReturnType<typeof useDialog>,
prompts: NonNullable<IntegrationOAuthMethod["prompts"]>,
) {
const inputs: Record<string, string> = {}
for (const prompt of prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.type === "select") {
const value = await new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((option) => ({
title: option.label,
value: option.value,
description: option.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
continue
}
const value = await new Promise<string | null>((resolve) => {
dialog.replace(
() => <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={resolve} />,
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}
async function connected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
toast.show({ variant: "success", message: `Connected ${name}` })
dialog.clear()
}
async function disconnected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
toast.show({ variant: "success", message: `Disconnected ${name}` })
dialog.clear()
}
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
}
function message(cause: unknown) {
if (cause instanceof Error) return cause.message
return "Authentication failed"
}
+56 -84
View File
@@ -1,22 +1,23 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogIntegration } from "./dialog-integration"
import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useSync } from "../context/sync"
import { useData } from "../context/data"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const data = useData()
const dialog = useDialog()
const [query, setQuery] = createSignal("")
const connected = useConnected()
const providers = createDialogProviderOptions()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
@@ -29,21 +30,20 @@ export function DialogModel(props: { providerID?: string }) {
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID)
if (!model) return []
const provider = providers().get(model.providerID)
return [
{
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
value: { providerID: model.providerID, modelID: model.id },
title: model.name,
releaseDate: model.time.released,
description: provider?.name ?? model.providerID,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
footer: free(model) ? "Free" : undefined,
onSelect: () => {
onSelect(provider.id, model.id)
onSelect(model.providerID, model.id)
},
},
]
@@ -58,80 +58,48 @@ export function DialogModel(props: { providerID?: string }) {
"Recent",
)
const providerOptions = pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => ({
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
releaseDate: info.release_date,
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
onSelect(provider.id, model)
},
})),
filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
const modelOptions = sortModelOptions(
models()
.filter((model) => model.status !== "deprecated")
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => ({
value: { providerID: model.providerID, modelID: model.id },
title: model.name,
releaseDate: model.time.released,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
onSelect(model.providerID, model.id)
},
}))
.filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
return false
if (
recents.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
return true
}),
(options) => sortModelOptions(options, props.providerID !== undefined),
),
),
)
return false
if (
recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
)
return false
return true
}),
props.providerID !== undefined,
)
const popularProviders = !connected()
? pipe(
providers(),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []
if (needle) {
return [
...sortModelOptions(
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
false,
),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
return [...favoriteOptions, ...recentOptions, ...modelOptions]
})
const provider = createMemo(() =>
props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
)
const provider = createMemo(() => (props.providerID ? providers().get(props.providerID) : undefined))
const title = createMemo(() => {
const value = provider()
@@ -142,8 +110,8 @@ export function DialogModel(props: { providerID?: string }) {
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list()
const cur = local.model.variant.selected()
if (cur === "default" || (cur && list.includes(cur))) {
const cur = local.model.variant.current()
if (cur && list.includes(cur)) {
dialog.clear()
return
}
@@ -160,9 +128,9 @@ export function DialogModel(props: { providerID?: string }) {
actions={[
{
command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers",
title: connected() ? "Connect integration" : "View all integrations",
onTrigger() {
dialog.replace(() => <DialogProvider />)
dialog.replace(() => <DialogIntegration />)
},
},
{
@@ -195,3 +163,7 @@ export function sortModelOptions<T extends { footer?: string; releaseDate: strin
(option) => option.title,
)
}
function free(model: { cost: Array<{ input: number }> }) {
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
}
@@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
try {
await sdk.client.v2.projectCopy.refresh(
{ projectID, location: { directory: sdk.directory } },
{ projectID, location: { directory: projectContext.instance.directory() || paths.cwd } },
{ throwOnError: true },
)
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
@@ -224,7 +224,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const result = await sdk.client.v2.projectCopy
.remove({
projectID: props.projectID,
location: { directory: sdk.directory },
location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory,
force: false,
})
@@ -246,7 +246,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const forced = await sdk.client.v2.projectCopy
.remove({
projectID: props.projectID,
location: { directory: sdk.directory },
location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory,
force: true,
})
@@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) {
<DialogPrompt
title={props.title}
placeholder="API key"
description={
{
description={() =>
({
opencode: (
<box gap={1}>
<text fg={theme.textMuted}>
@@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) {
</text>
</box>
),
}[props.providerID] ?? undefined
})[props.providerID] ?? undefined
}
onConfirm={async (value) => {
if (!value) return
@@ -1,203 +1,54 @@
import { createMemo, createResource, onMount } from "solid-js"
import path from "path"
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
import path from "path"
import { useData } from "../context/data"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { useLocal } from "../context/local"
import { DialogSessionRename } from "./dialog-session-rename"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useCommandShortcut } from "../keymap"
import { useEvent } from "../context/event"
type SessionListFilter = { scope?: "project"; path?: string }
export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
const search = input.search?.trim()
return {
roots: true,
limit: search ? 30 : 100,
...(search ? { search } : {}),
...input.filter,
}
}
export function loadDialogSessionList<T>(input: {
search?: string
filter: SessionListFilter
list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
}) {
return input.list(createDialogSessionListQuery(input)).then(
(result) => result.data,
() => undefined,
)
}
import { Spinner } from "./spinner"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const data = useData()
const project = useProject()
const { theme } = useTheme()
const sdk = useSDK()
const event = useEvent()
const local = useLocal()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [deleted, setDeleted] = createSignal(new Set<string>())
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [browseResults, { refetch: refetchBrowse }] = createResource(
() => sync.session.query(),
(filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }),
)
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
(input) => {
if (!input.query) return undefined
return loadDialogSessionList({
search: input.query,
filter: input.filter,
list: (query) => sdk.client.session.list(query),
})
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query) return
const response = await sdk.client.v2.session.list(
{ search: query, limit: 50, order: "desc" },
{ throwOnError: true },
)
return { query, sessions: response.data.data }
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
const result = searchResults() ?? browseResults() ?? sync.data.session
const synced = new Map(sync.data.session.map((session) => [session.id, session]))
const ids = new Set(result.map((session) => session.id))
const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => {
if (!id || ids.has(id)) return []
const session = synced.get(id)
if (session) ids.add(id)
return session ? [session] : []
})
const query = search().trim().toLowerCase()
return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
.filter((session) => !deleted().has(session.id))
.filter((session) => !query || session.title.toLowerCase().includes(query))
const query = search()
if (!query) return data.session.list()
const result = searchResults()
return result?.query === query ? result.sessions : []
})
onCleanup(
event.on("session.deleted", (event) => {
setDeleted((current) => new Set(current).add(event.properties.info.id))
}),
)
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(err),
variant: "error",
})
return
}
const workspace = result?.data
if (!workspace) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
await refetchBrowse()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
))
}
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
if (!first || !last) return undefined
if (!first || !last) return
return quickSwitchRange(first, last)
})
const quickSwitchFooterHints = createMemo(() => {
@@ -207,149 +58,70 @@ export function DialogSessionList() {
const options = createMemo(() => {
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
.filter((x) => x.parentID === undefined)
.map((x) => [x.id, x]),
)
const searchResult = searchResults()
const order = searchResult ? orderByRecency(sessions()) : browseOrder()
const current = currentSessionID()
const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session]))
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
function buildOption(id: string, category: string) {
const x = sessionMap.get(id)
if (!x) return undefined
const directory = x.path
? x.directory.endsWith(x.path)
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
: undefined
: x.directory
const footer =
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id)
const gutter = isWorking
? () => <Spinner />
: slot !== undefined
? () => <text fg={theme.accent}>{slot}</text>
: undefined
const option = (session: SessionV2Info, category: string) => {
const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
title: session.title,
value: session.id,
category,
footer,
gutter,
gutter:
data.session.status(session.id) === "running"
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.accent}>{slot}</text>,
}
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
const label = new Date(x.time.updated).toDateString()
return buildOption(id, label === today ? "Today" : label)
const remaining = sessions()
.filter((session) => !session.parentID && !pinnedSet.has(session.id))
.map((session) => {
const date = new Date(session.time.updated).toDateString()
return option(session, date === today ? "Today" : date)
})
.filter((x) => x !== undefined)
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining]
})
onMount(() => {
dialog.setSize("large")
})
onMount(() => dialog.setSize("large"))
const unavailable = (feature: string) =>
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
return (
<DialogSelect
title="Sessions"
options={options()}
skipFilter={true}
preserveSelection={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
route.navigate({ type: "session", sessionID: option.value })
dialog.clear()
}}
actions={[
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
},
{
command: "session.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
await refetchBrowse()
if (search()) await refetch()
setToDelete(undefined)
return
}
setToDelete(option.value)
},
onTrigger: () => unavailable("Deleting"),
},
{
command: "session.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
onTrigger: () => unavailable("Renaming"),
},
]}
footerHints={quickSwitchFooterHints()}
+10 -20
View File
@@ -7,32 +7,22 @@ export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() => {
return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
const options = createMemo(() =>
local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
...local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
})),
)
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.selected()}
current={local.model.variant.current()}
flat={true}
/>
)
@@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: {
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
}) {
const dir = input.sync.path.directory || input.sdk.directory
const dir = input.sync.path.directory || process.cwd()
try {
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
if (response.error) throw response.error
@@ -400,15 +400,15 @@ export function Autocomplete(props: {
})
const agents = createMemo(() => {
return sync.data.agent
return (data.location.agent.list() ?? [])
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
display: "@" + agent.id,
onSelect: () => {
insertPart(agent.name, {
insertPart(agent.id, {
type: "agent",
name: agent.name,
name: agent.id,
source: {
start: 0,
end: 0,
+124 -165
View File
@@ -40,11 +40,10 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { 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,11 @@ 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 history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
@@ -207,6 +208,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 +217,8 @@ export function Prompt(props: PromptProps) {
message: "Connect a provider to send prompts",
duration: 3000,
})
if (sync.data.provider.length === 0) {
dialog.replace(() => <DialogProviderConnect />)
if (!connected()) {
dialog.replace(() => <DialogIntegration />)
}
}
@@ -303,28 +305,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 +383,7 @@ export function Prompt(props: PromptProps) {
name: "session.interrupt",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
if (!input.focused) return
@@ -982,6 +976,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 +986,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 +998,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 +1006,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 +1047,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 +1071,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 +1310,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 +1341,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 +1465,7 @@ export function Prompt(props: PromptProps) {
{(agent) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
@@ -1501,77 +1527,12 @@ export function Prompt(props: PromptProps) {
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
@@ -1633,41 +1594,39 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</box>
</box>
<Autocomplete
+1 -1
View File
@@ -40,7 +40,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const result = await sdk.client.v2.projectCopy.create(
{
projectID,
location: { directory: sdk.directory },
location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: generated.data.name,
@@ -0,0 +1,32 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { attempt: number; error?: string }) {
const theme = useTheme().theme
return (
<box
position="absolute"
zIndex={10_000}
top={0}
right={0}
bottom={0}
left={0}
backgroundColor={theme.background}
alignItems="center"
justifyContent="center"
>
<box width={54} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<text fg={theme.text}>Connection lost</text>
<Spinner color={theme.textMuted}>Reconnecting to server...</Spinner>
<text fg={theme.textMuted}>Attempt {props.attempt}</text>
<Show when={props.error}>
<text fg={theme.error} wrapMode="word">
{props.error}
</text>
</Show>
</box>
</box>
)
}
+6 -5
View File
@@ -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,
)
}
+1 -1
View File
@@ -123,7 +123,7 @@ export const Definitions = {
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
mcp_list: keybind("none", "List MCP servers"),
provider_connect: keybind("none", "Connect provider"),
provider_connect: keybind("none", "Connect integration"),
console_org_switch: keybind("none", "Switch console organization"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
+256 -87
View File
@@ -21,8 +21,14 @@ 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 { createGlobalEmitter } from "@solid-primitives/event-bus"
export type DataConnectionStatus = "connecting" | "connected" | "reconnecting"
export type DataSessionStatus = "idle" | "running"
export type DataEvent = V2Event
type DataEventMap = { [T in DataEvent["type"]]: Extract<DataEvent, { type: T }> }
type LocationData = {
agent?: AgentV2Info[]
@@ -35,8 +41,14 @@ type LocationData = {
}
type Data = {
connection: {
status: DataConnectionStatus
attempt: number
error?: string
}
session: {
info: Record<string, SessionV2Info>
status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
@@ -59,8 +71,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
const [store, setStore] = createStore<Data>({
connection: {
status: "connecting",
attempt: 0,
},
session: {
info: {},
status: {},
message: {},
permission: {},
question: {},
@@ -72,35 +89,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
const sdk = useSDK()
const events = useEvent()
const events = createGlobalEmitter<DataEventMap>()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const message = {
update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
setStore(
"session",
"message",
produce((draft) => {
fn((draft[sessionID] ??= []))
fn((draft[sessionID] ??= []), index(sessionID))
}),
)
},
prepend(messages: SessionMessage[], item: SessionMessage) {
if (messages.some((existing) => existing.id === item.id)) return
messages.unshift(item)
append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
if (index.has(item.id)) return
index.set(item.id, messages.length)
messages.push(item)
},
activeAssistant(messages: SessionMessage[]) {
const item = messages.find((item) => item.type === "assistant" && !item.time.completed)
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessage[], messageID: string) {
const item = messages.find((item) => item.type === "assistant" && item.id === messageID)
assistant(messages: SessionMessage[], index: Map<string, number>, messageID: string) {
const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined
},
activeShell(messages: SessionMessage[], callID: string) {
const item = messages.find((item) => item.type === "shell" && item.callID === callID)
const item = messages.findLast((item) => item.type === "shell" && item.callID === callID)
return item?.type === "shell" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
@@ -121,6 +141,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}
function index(sessionID: string) {
const existing = messageIndex.get(sessionID)
if (existing) return existing
const created = new Map<string, number>()
messageIndex.set(sessionID, created)
return created
}
function handleEvent(event: V2Event) {
switch (event.type) {
case "catalog.updated":
@@ -130,8 +158,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 +170,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 +182,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 +198,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 +208,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 +219,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 +232,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 +241,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 +258,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 +271,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 +281,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 +290,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 +313,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 +334,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 +361,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 +381,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 +431,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
@@ -400,35 +477,64 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
break
}
events.emit(event.type, event)
}
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: events.on,
listen: events.listen,
connection: {
status() {
return store.connection.status
},
attempt() {
return store.connection.attempt
},
error() {
return store.connection.error
},
},
session: {
list() {
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
},
get(sessionID: string) {
return store.session.info[sessionID]
},
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
setStore("session", "info", sessionID, result.data.data)
},
message: {
ids(sessionID: string) {
return (store.session.message[sessionID] ?? []).map((message) => message.id)
},
list(sessionID: string) {
return store.session.message[sessionID]
return store.session.message[sessionID] ?? []
},
get(sessionID: string, messageID: string) {
const messages = store.session.message[sessionID]
const position = messageIndex.get(sessionID)?.get(messageID)
return position === undefined ? undefined : messages?.[position]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
setStore("session", "message", sessionID, result.data.data)
setStore("session", "message", sessionID, [])
messageIndex.set(sessionID, new Map())
const response = await sdk.client.v2.session.messages(
{ sessionID, limit: 200, order: "desc" },
{ throwOnError: true },
)
const loaded = response.data.data.toReversed()
const live = store.session.message[sessionID] ?? []
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live]
.filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index)
.toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
},
},
permission: {
@@ -479,7 +585,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "agent", result.data.data)
setStore("location", key, { ...store.location[key], agent: result.data.data })
},
},
command: {
@@ -489,7 +595,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "command", result.data.data)
setStore("location", key, { ...store.location[key], command: result.data.data })
},
},
integration: {
@@ -502,7 +608,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
{ throwOnError: true },
)
const key = locationKey(result.data.location)
setStore("location", key, "integration", result.data.data)
setStore("location", key, { ...store.location[key], integration: result.data.data })
},
},
model: {
@@ -512,7 +618,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "model", result.data.data)
setStore("location", key, { ...store.location[key], model: result.data.data })
},
},
provider: {
@@ -522,7 +628,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "provider", result.data.data)
setStore("location", key, { ...store.location[key], provider: result.data.data })
},
},
reference: {
@@ -532,7 +638,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "reference", result.data.data)
setStore("location", key, { ...store.location[key], reference: result.data.data })
},
},
skill: {
@@ -542,14 +648,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "skill", result.data.data)
setStore("location", key, { ...store.location[key], skill: result.data.data })
},
},
},
}
onMount(() => {
void Promise.allSettled([
async function bootstrap() {
const settled = await Promise.allSettled([
sdk.client.v2.session
.list({ limit: 50, order: "desc" }, { throwOnError: true })
.then((response) =>
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data.data) draft[session.id] = session
}),
),
),
sdk.client.v2.session.active({ throwOnError: true }).then((response) =>
setStore(
"session",
"status",
Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])),
),
),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),
@@ -558,12 +682,57 @@ 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)
}
onMount(() => {
const controller = new AbortController()
onCleanup(() => controller.abort())
void (async () => {
while (!controller.signal.aborted) {
const error = await (async () => {
const events = await sdk.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]()
const first = await stream.next()
if (first.done) return new Error("Event stream disconnected")
setStore("connection", { status: "connected", attempt: 0, error: undefined })
handleEvent(first.value)
await bootstrap()
while (!controller.signal.aborted) {
const event = await stream.next()
if (event.done) return new Error("Event stream disconnected")
handleEvent(event.value)
}
})().catch((error) => error)
if (controller.signal.aborted) return
setStore("connection", {
status: "reconnecting",
attempt: store.connection.status === "connected" ? 1 : store.connection.attempt + 1,
error: error instanceof Error ? error.message : String(error),
})
await wait(250, controller.signal)
}
})()
})
return result
},
})
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
+46 -38
View File
@@ -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])
@@ -521,10 +529,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createEffect(() => {
const value = agent.current()
if (!value?.model) return
if (isModelValid(value.model)) return
if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`,
duration: 3000,
})
})
+1 -1
View File
@@ -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({
+15 -77
View File
@@ -1,37 +1,12 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
}
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 }) => {
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) {
@@ -57,21 +32,15 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
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)
}
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
@@ -79,73 +48,42 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
flush()
}
function startSSE() {
sse?.abort()
const ctrl = new AbortController()
sse = ctrl
;(async () => {
onMount(() => {
void (async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({
signal: ctrl.signal,
while (!abort.signal.aborted) {
const events = await props.client.global.event({
signal: abort.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(() => {})
}
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {})
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
if (abort.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))
if (abort.signal.aborted) break
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)),
)
}
})().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()
}
})
onCleanup(() => {
abort.abort()
sse?.abort()
if (timer) clearTimeout(timer)
handlers.clear()
})
return {
get client() {
return sdk
},
directory: props.directory,
client: props.client,
event: emitter,
fetch: props.fetch ?? fetch,
url: props.url,
}
},
})
+42 -574
View File
@@ -1,55 +1,33 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Todo,
Command,
PermissionRequest,
QuestionRequest,
LspStatus,
McpStatus,
McpResource,
FormatterStatus,
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
SnapshotFileDiff,
Config,
ConsoleState,
FormatterStatus,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
PermissionRequest,
Provider,
ProviderAuthMethod,
ProviderListResponse,
QuestionRequest,
Session,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "./project"
import { useEvent } from "./event"
import { useSDK } from "./sdk"
import { useTuiStartup } from "./runtime"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import path from "path"
import { useKV } from "./kv"
import { useProject } from "./project"
const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
switchableOrgCount: 0,
}
function search<T>(items: T[], target: string, key: (item: T) => string) {
let left = 0
let right = items.length - 1
while (left <= right) {
const middle = Math.floor((left + right) / 2)
const value = key(items[middle])
if (value === target) return { found: true, index: middle }
if (value < target) left = middle + 1
else right = middle - 1
}
return { found: false, index: left }
}
export const {
context: SyncContext,
use: useSync,
@@ -57,8 +35,7 @@ export const {
} = createSimpleContext({
name: "Sync",
init: () => {
const startup = useTuiStartup()
const kv = useKV()
const project = useProject()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
@@ -71,39 +48,23 @@ export const {
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
permission: Record<string, PermissionRequest[]>
question: Record<string, QuestionRequest[]>
config: Config
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
session_diff: Record<string, SnapshotFileDiff[]>
todo: Record<string, Todo[]>
message: Record<string, Message[]>
part: Record<string, Part[]>
lsp: LspStatus[]
mcp: {
[key: string]: McpStatus
}
mcp_resource: {
[key: string]: McpResource
}
mcp: Record<string, McpStatus>
mcp_resource: Record<string, McpResource>
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
status: "complete",
provider: [],
provider_default: {},
provider_next: {
all: [],
default: {},
@@ -114,16 +75,12 @@ export const {
experimentalBackgroundSubagents: false,
},
provider_auth: {},
config: {},
status: "loading",
agent: [],
command: [],
permission: {},
question: {},
command: [],
provider: [],
provider_default: {},
config: {},
session: [],
session_status: {},
session_diff: {},
todo: {},
message: {},
@@ -135,521 +92,32 @@ export const {
vcs: undefined,
})
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
const touchMessage = (sessionID: string, messageID: string) => {
hydratingSessions.get(sessionID)?.messages.add(messageID)
}
const touchPart = (sessionID: string, partID: string) => {
hydratingSessions.get(sessionID)?.parts.add(partID)
}
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event, { workspace }) => {
switch (event.type) {
case "server.instance.disposed":
void bootstrap()
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const requests = store.question[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "question.asked": {
const request = event.properties
const requests = store.question[request.sessionID]
if (!requests) {
setStore("question", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("question", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"question",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "session.deleted": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "session.updated": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.next.moved": {
const result = search(store.session, event.properties.sessionID, (s) => s.id)
if (!result.found) break
setStore(
"session",
result.index,
produce((session) => {
session.directory = event.properties.location.directory
session.path = event.properties.subdirectory
session.workspaceID = event.properties.location.workspaceID
session.time.updated = event.properties.timestamp
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
touchMessage(event.properties.info.sessionID, event.properties.info.id)
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
touchMessage(event.properties.sessionID, event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
touchPart(event.properties.part.sessionID, event.properties.part.id)
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
case "message.part.delta": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = search(parts, event.properties.partID, (p) => p.id)
if (!result.found) break
touchPart(event.properties.sessionID, event.properties.partID)
setStore(
"part",
event.properties.messageID,
produce((draft) => {
const part = draft[result.index]
const field = event.properties.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + event.properties.delta
}),
)
break
}
case "message.part.removed": {
touchPart(event.properties.sessionID, event.properties.partID)
const parts = store.part[event.properties.messageID]
const result = search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "lsp.updated": {
const workspace = project.workspace.current()
void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
break
}
case "vcs.branch.updated": {
if (workspace === project.workspace.current()) {
setStore("vcs", { branch: event.properties.branch })
}
break
}
}
})
const exit = useExit()
const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const capabilitiesPromise = sdk.client.experimental.capabilities
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => undefined)
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
await Promise.all([
providersPromise,
providerListPromise,
capabilitiesPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
])
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const capabilitiesResponse = capabilitiesPromise
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
return Promise.all([
providersResponse,
providerListResponse,
capabilitiesResponse,
consoleStateResponse,
agentsResponse,
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const capabilities = responses[2]
const consoleState = responses[3]
const agents = responses[4]
const config = responses[5]
const sessions = responses[6]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions))
})
})
})
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
void Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data ?? {}))
}),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
setStore("status", "complete")
})
})
.catch(async (e) => {
console.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
if (fatal) {
exit(e)
} else {
throw e
}
})
}
onMount(() => {
void bootstrap()
})
const result = {
return {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
if (startup.skipInitialLoading) return true
return store.status !== "loading"
return true
},
get path() {
return project.instance.path()
},
session: {
get(sessionID: string) {
const match = search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
get(_sessionID: string) {
return undefined as Session | undefined
},
query() {
return sessionListQuery()
return {} as { scope?: "project"; path?: string }
},
async refresh() {
const list = await listSessions()
setStore("session", reconcile(list))
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
if (session.time.compacting) return "compacting"
const messages = store.message[sessionID] ?? []
const last = messages.at(-1)
if (!last) return "idle"
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const syncing = syncingSessions.get(sessionID)
if (syncing) return syncing
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
hydratingSessions.set(sessionID, tracker)
const task = (async () => {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const currentMessages = draft.message[sessionID] ?? []
const infos = (messages.data ?? []).flatMap((message) => {
if (!tracker.messages.has(message.info.id)) return [message.info]
const current = currentMessages.find((item) => item.id === message.info.id)
return current ? [current] : []
})
infos.push(
...currentMessages.filter(
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
),
)
const removed = infos.slice(0, -100)
const visible = infos.slice(-100)
const visibleIDs = new Set(visible.map((message) => message.id))
for (const message of messages.data ?? []) {
if (!visibleIDs.has(message.info.id)) {
delete draft.part[message.info.id]
continue
}
const currentParts = draft.part[message.info.id] ?? []
const parts = message.parts.flatMap((part) => {
const current = currentParts.find((item) => item.id === part.id)
if (tracker.parts.has(part.id)) return current ? [current] : []
if (
current &&
(part.type === "text" || part.type === "reasoning") &&
(current.type === "text" || current.type === "reasoning") &&
part.text.length === 0 &&
current.text.length > 0
) {
return [current]
}
return [part]
})
parts.push(
...currentParts.filter(
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
),
)
draft.part[message.info.id] = parts
}
for (const message of removed) delete draft.part[message.id]
draft.message[sessionID] = visible
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
})().finally(() => {
syncingSessions.delete(sessionID)
hydratingSessions.delete(sessionID)
})
syncingSessions.set(sessionID, task)
return task
async refresh() {},
status(_sessionID: string) {
return "idle" as const
},
async sync(_sessionID: string) {},
},
bootstrap,
async bootstrap(_input: { fatal?: boolean } = {}) {},
}
return result
},
})
+43 -2
View File
@@ -1,4 +1,5 @@
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime"
import HomeFooter from "./home/footer"
import HomeTips from "./home/tips"
import SidebarContext from "./sidebar/context"
@@ -11,6 +12,7 @@ import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
import Scrap from "./system/scrap"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
@@ -18,7 +20,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] {
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -31,6 +33,45 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
Notifications,
PluginManager,
WhichKey,
Scrap,
DiffViewer,
]
}
export async function loadBuiltinPlugins(
api: TuiPluginApi,
runtime: PluginRuntime,
) {
const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = []
for (const plugin of createBuiltinPlugins()) {
if (plugin.enabled === false) continue
const scoped = Object.assign(Object.create(api), {
slots: {
register(input: Parameters<typeof slots.register>[0]) {
dispose.push(slots.register({ ...input, id: plugin.id }))
return plugin.id
},
},
}) as TuiPluginApi
const now = Date.now()
await plugin.tui(scoped, undefined, {
id: plugin.id,
source: "internal",
spec: plugin.id,
target: plugin.id,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: plugin.id,
state: "first",
})
}
return () => {
for (const fn of dispose.reverse()) fn()
slots.dispose()
}
}
@@ -56,15 +56,12 @@ const tui: TuiPlugin = async (api) => {
permissions.delete(event.properties.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,6 +72,24 @@ 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.properties.sessionID))
api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID))
api.event.on("session.next.step.started", (event) => started(event.properties.sessionID))
api.event.on("session.next.retried", (event) => started(event.properties.sessionID))
api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID))
api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID))
api.event.on("session.next.step.ended", (event) => {
if (event.properties.finish === "tool-calls") return
ended(event.properties.sessionID)
})
api.event.on("session.next.step.failed", (event) => {
const sessionID = event.properties.sessionID
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, "Session error", "error")
ended(sessionID)
})
api.event.on("session.error", (event) => {
@@ -0,0 +1,66 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { useBindings } from "../../keymap"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:scrap"
const route = "scrap"
function Scrap(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
useBindings(() => ({
bindings: [
{
key: "escape",
desc: "Back home",
group: "Scrap",
cmd() {
props.api.route.navigate("home")
},
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
<box flexGrow={1} />
<box
height={1}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>esc home</text>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([{ name: route, render: () => <Scrap api={api} /> }])
api.keymap.registerLayer({
commands: [
{
name: "app.scrap",
title: "Open scrap screen",
category: "Debug",
namespace: "palette",
run() {
api.route.navigate(route)
api.ui.dialog.clear()
},
},
],
})
}
const plugin: BuiltinTuiPlugin = { id, tui }
export default plugin
+5 -3
View File
@@ -4,6 +4,7 @@ import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"
import type { useSync } from "../context/sync"
import type { useData } from "../context/data"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
@@ -31,6 +32,7 @@ type Input = {
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
@@ -95,7 +97,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return {
get ready() {
return sync.ready
@@ -135,7 +137,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.message[sessionID] ?? []
},
status(sessionID) {
return sync.data.session_status[sessionID]
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
},
permission(sessionID) {
return sync.data.permission[sessionID] ?? []
@@ -297,7 +299,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
return input.kv.ready
},
},
state: stateApi(input.sync),
state: stateApi(input.sync, input.data),
get client() {
return input.sdk.client
},
@@ -1,22 +1,16 @@
import { createMemo } from "solid-js"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useClipboard } from "../../context/clipboard"
import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part"
import { useToast } from "../../ui/toast"
export function DialogMessage(props: {
messageID: string
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
const message = createMemo(() =>
data.session.message.get(props.sessionID, props.messageID),
)
return (
<DialogSelect
@@ -27,29 +21,7 @@ export function DialogMessage(props: {
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
const msg = message()
if (!msg) return
void sdk.client.session.revert({
sessionID: props.sessionID,
messageID: msg.id,
})
if (props.setPrompt) {
const parts = sync.data.part[msg.id]
const promptInfo = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
props.setPrompt(promptInfo)
}
toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
},
},
@@ -58,17 +30,19 @@ export function DialogMessage(props: {
value: "message.copy",
description: "message text to clipboard",
onSelect: async (dialog) => {
const msg = message()
if (!msg) return
const parts = sync.data.part[msg.id]
const text = parts.reduce((agg, part) => {
if (part.type === "text" && !part.synthetic) {
agg += part.text
}
return agg
}, "")
const value = message()
if (!value) return
const text =
value.type === "user"
? value.text
: value.type === "assistant"
? value.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("\n")
: "text" in value
? value.text
: ""
await clipboard.write?.(text)
dialog.clear()
},
@@ -77,29 +51,8 @@ export function DialogMessage(props: {
title: "Fork",
value: "session.fork",
description: "create a new session",
onSelect: async (dialog) => {
const result = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: props.messageID,
})
const msg = message()
const prompt = msg
? sync.data.part[msg.id].reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
: undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
prompt,
})
onSelect: (dialog) => {
toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
},
},
File diff suppressed because it is too large Load Diff
+73 -52
View File
@@ -4,11 +4,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionV2Request } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useSync } from "../../context/sync"
import { useProject } from "../../context/project"
import { useData } from "../../context/data"
import { filetype } from "../../util/filetype"
import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display"
@@ -19,7 +18,7 @@ import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject"
function EditBody(props: { request: PermissionRequest }) {
function EditBody(props: { request: PermissionV2Request; patch?: string }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
@@ -27,8 +26,7 @@ function EditBody(props: { request: PermissionRequest }) {
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => {
const value = props.request.metadata?.filepath
return typeof value === "string" ? value : ""
return props.request.resources[0] ?? ""
})
const diff = createMemo(() => {
const value = props.request.metadata?.diff
@@ -79,9 +77,36 @@ function EditBody(props: { request: PermissionRequest }) {
</scrollbox>
</Show>
<Show when={!diff()}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
</box>
<Show
when={props.patch}
fallback={
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
</box>
}
>
{(patch) => (
<scrollbox
height="100%"
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
},
}}
>
<code
filetype="diff"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={patch()}
fg={theme.textMuted}
/>
</scrollbox>
)}
</Show>
</Show>
</box>
)
@@ -108,26 +133,22 @@ function TextBody(props: { title: string; description?: string; icon?: string })
)
}
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
const sdk = useSDK()
const project = useProject()
const sync = useSync()
const data = useData()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
const session = createMemo(() => data.session.get(props.request.sessionID))
const input = createMemo(() => {
const tool = props.request.tool
const tool = props.request.source
if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? []
for (const part of parts) {
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
return part.state.input ?? {}
}
}
const message = data.session.message.get(props.request.sessionID, tool.messageID)
if (message?.type !== "assistant") return {}
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "pending") return part.state.input
return {}
})
@@ -140,14 +161,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
<Match when={props.request.save?.length === 1 && props.request.save[0] === "*"}>
<TextBody title={"This will allow " + props.request.action + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
<For each={props.request.save ?? []}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
@@ -165,11 +186,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "always",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
}}
/>
@@ -177,12 +197,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
directory: props.directory,
message: message || undefined,
workspace: project.workspace.current(),
})
}}
onCancel={() => {
@@ -193,21 +212,21 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<Match when={store.stage === "permission"}>
{(() => {
const info = () => {
const permission = props.request.permission
const permission = props.request.action
const data = input()
if (permission === "edit") {
const raw = props.request.metadata?.filepath
const filepath = typeof raw === "string" ? raw : ""
const filepath = props.request.resources[0] ?? ""
const patch = typeof data.patchText === "string" ? data.patchText : undefined
return {
icon: "→",
title: `Edit ${pathFormatter.format(filepath)}`,
body: <EditBody request={props.request} />,
body: <EditBody request={props.request} patch={patch} />,
}
}
if (permission === "read") {
const raw = data.filePath
const raw = data.path
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
@@ -271,8 +290,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
if (permission === "bash") {
const command = typeof data.command === "string" ? data.command : ""
return {
icon: "#",
title: "Shell command",
body: (
<Show when={command}>
<box paddingLeft={1}>
@@ -333,13 +350,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const pattern = props.request.resources[0]
const derived =
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = pathFormatter.format(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
const patterns = props.request.resources.filter((p): p is string => typeof p === "string")
return {
icon: "←",
@@ -388,12 +405,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text>
</box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
<Show when={current.title}>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
</Show>
</box>
)
@@ -402,7 +421,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
title="Permission required"
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
options={
props.request.save?.length
? { once: "Allow once", always: "Allow always", reject: "Reject" }
: { once: "Allow once", reject: "Reject" }
}
escapeKey="reject"
fullscreen
onSelect={(option) => {
@@ -415,19 +438,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
setStore("stage", "reject")
return
}
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
return
}
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "once",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
}}
/>
+11 -11
View File
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useTuiConfig } from "../../config"
@@ -11,7 +11,7 @@ import { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) {
export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
@@ -24,7 +24,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
answers: [] as QuestionV2Answer[],
custom: [] as string[],
selected: 0,
editing: false,
@@ -47,17 +47,17 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.client.question.reply({
void sdk.client.v2.session.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
directory: props.directory,
answers,
questionV2Reply: { answers },
})
}
function reject() {
void sdk.client.question.reject({
void sdk.client.v2.session.question.reject({
sessionID: props.request.sessionID,
requestID: props.request.id,
directory: props.directory,
})
}
@@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
setStore("custom", inputs)
}
if (single()) {
void sdk.client.question.reply({
void sdk.client.v2.session.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
directory: props.directory,
answers: [[answer]],
questionV2Reply: { answers: [[answer]] },
})
return
}
+195
View File
@@ -0,0 +1,195 @@
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useData } from "../../context/data"
export type PartRef = {
messageID: string
partID: string
}
export type SessionRow =
| { type: "message"; messageID: string }
| { type: "part"; ref: PartRef }
| {
type: "group"
kind: "exploration"
refs: PartRef[]
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
const [rows, setRows] = createStore<SessionRow[]>([])
createEffect(() => {
const pending = new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
request.source?.type === "tool" ? [request.source.callID] : [],
),
)
setRows(
produce((draft) => {
draft.forEach((row) => {
if (row.type !== "group") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
}),
)
})
createEffect(
on(sessionID, (id) => {
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
void data.session.message.refresh(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
},
() => undefined,
)
}),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
completePrevious(draft)
draft.push({ type: "message", messageID })
}),
)
const appendPart = (ref: PartRef, name?: string) =>
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
if (name && exploration(name)) {
const previous = draft.at(-1)
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(draft)
draft.push({
type: "group",
kind: "exploration",
refs: [ref],
pending: [],
completed: false,
})
return
}
completePrevious(draft)
draft.push({ type: "part", ref })
}),
)
const appendFooter = (messageID: string) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
completePrevious(draft)
draft.push({ type: "assistant-footer", messageID })
}),
)
const message = (event: { data: { sessionID: string; messageID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
}
const subscriptions = [
data.on("session.next.prompted", message),
data.on("session.next.context.updated", message),
data.on("session.next.synthetic", message),
data.on("session.next.shell.started", message),
data.on("session.next.agent.switched", message),
data.on("session.next.model.switched", message),
data.on("session.next.compaction.ended", message),
data.on("session.next.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}),
data.on("session.next.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}),
data.on("session.next.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}),
data.on("session.next.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}),
data.on("session.next.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
}),
data.on("session.next.step.ended", (event) => {
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID)
}),
data.on("session.next.step.failed", (event) => {
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
return rows
}
export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
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)
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,
)
})
}
+8 -10
View File
@@ -1,5 +1,5 @@
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../config"
@@ -12,12 +12,12 @@ import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const project = useProject()
const sync = useSync()
const data = useData()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const session = createMemo(() => data.session.get(props.sessionID))
const workspace = () => {
const workspaceID = session()?.workspaceID
const workspaceID = session()?.location.workspaceID
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
@@ -51,7 +51,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
mode="single_winner"
session_id={props.sessionID}
title={session()!.title}
share_url={session()!.share?.url}
>
<box paddingRight={1}>
<text fg={theme.text}>
@@ -60,11 +59,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<Show when={session()!.location.workspaceID}>
<text fg={theme.textMuted}>
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
fallback={
<WorkspaceLabel type="unknown" name={session()!.location.workspaceID!} status="error" icon />
}
>
{(item) => (
<WorkspaceLabel
@@ -77,9 +78,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
</text>
</Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
</box>
</pluginRuntime.Slot>
<pluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
@@ -1,47 +1,40 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
const sync = useSync()
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const session = createMemo(() => sync.session.get(route.sessionID))
const data = useData()
const session = createMemo(() => data.session.get(route.sessionID))
const subagentInfo = createMemo(() => {
const s = session()
if (!s) return { label: "Subagent", index: 0, total: 0 }
if (!s) return "Subagent"
const agentMatch = s.title.match(/@(\w+) subagent/)
const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
if (!s.parentID) return { label, index: 0, total: 0 }
const siblings = sync.data.session
.filter((x) => x.parentID === s.parentID)
.toSorted((a, b) => a.time.created - b.time.created)
const index = siblings.findIndex((x) => x.id === s.id)
return { label, index: index + 1, total: siblings.length }
return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
})
const usage = createMemo(() => {
const msg = messages()
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return
const current = session()
if (!current) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
current.tokens.input +
current.tokens.output +
current.tokens.reasoning +
current.tokens.cache.read +
current.tokens.cache.write
if (tokens <= 0) return
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(current.location)
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = session()?.cost ?? 0
const cost = current.cost
const money = new Intl.NumberFormat("en-US", {
style: "currency",
@@ -78,13 +71,8 @@ export function SubagentFooter() {
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>
<b>{subagentInfo().label}</b>
<b>{subagentInfo()}</b>
</text>
<Show when={subagentInfo().total > 0}>
<text style={{ fg: theme.textMuted }}>
({subagentInfo().index} of {subagentInfo().total})
</text>
</Show>
<Show when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
@@ -95,10 +83,10 @@ export function SubagentFooter() {
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
+1 -1
View File
@@ -83,7 +83,7 @@ export function DialogPrompt(props: DialogPromptProps) {
</text>
</box>
<box gap={1}>
{props.description}
{props.description?.()}
<textarea
height={3}
ref={(val: TextareaRenderable) => {
-25
View File
@@ -1,25 +0,0 @@
import type { BaseRenderable, BoxRenderable } from "@opentui/core"
const previousByParent = new WeakMap<
BaseRenderable,
{ frameID: number; previous: WeakMap<BaseRenderable, BaseRenderable | undefined> }
>()
export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) {
// Run before Yoga layout so scroll geometry matches the rendered frame.
el.onLifecyclePass = () => {
const parent = el.parent
if (!parent) return
const cached = previousByParent.get(parent)
const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId)
const value = margin(previous.get(el))
if (el.marginTop !== value) el.marginTop = value
}
}
function previousSiblings(parent: BaseRenderable, frameID: number) {
const previous = new WeakMap<BaseRenderable, BaseRenderable | undefined>()
parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1]))
previousByParent.set(parent, { frameID, previous })
return previous
}
+26 -26
View File
@@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -17,8 +17,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
setTitle(title)
}
const listeners = new Set(process.listeners("SIGHUP"))
const events = createEventSource()
const calls = createFetch()
const events = createEventStream()
const calls = createFetch(undefined, events)
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
@@ -29,11 +29,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
client: createClient(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: {},
pluginHost: {
async start() {
@@ -63,21 +60,27 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventSource()
const calls = createFetch((url) => {
if (url.pathname === "/session")
return json([
{
id: "dummy",
title: "Demo session",
slug: "dummy",
projectID: "project",
directory,
version: "0.0.0-test",
time: { created: 0, updated: 0 },
},
])
})
const events = createEventStream()
const calls = createFetch(
(url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
],
cursor: {},
})
},
events,
)
const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = ""
let api: TuiPluginApi | undefined
@@ -95,11 +98,8 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
client: createClient(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: { continue: true },
pluginHost: {
async start(input) {
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode-ai/sdk/v2"
import {
connectionSummary,
connectMethods,
credentialConnections,
integrationOptions,
} from "../../../../src/component/dialog-integration"
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
methods: [],
connections: [],
...value,
})
describe("integrationOptions", () => {
test("keeps popular integrations first and sorts the rest alphabetically", () => {
expect(
integrationOptions([
integration({ id: "mistral", name: "Mistral" }),
integration({ id: "openai", name: "OpenAI" }),
integration({ id: "custom-z", name: "Zebra" }),
integration({ id: "anthropic", name: "Anthropic" }),
]).map((item) => item.id),
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
})
})
describe("connectMethods", () => {
test("offers key and OAuth methods but not environment discovery", () => {
expect(
connectMethods(
integration({
id: "example",
name: "Example",
methods: [
{ type: "env", names: ["EXAMPLE_KEY"] },
{ type: "key", label: "API key" },
{ type: "oauth", id: "account", label: "Account" },
],
}),
).map((method) => method.type),
).toEqual(["oauth", "key"])
})
})
describe("credentialConnections", () => {
test("returns removable credential connections only", () => {
expect(
credentialConnections(
integration({
id: "example",
name: "Example",
connections: [
{ type: "env", name: "EXAMPLE_KEY" },
{ type: "credential", id: "cred_1", label: "Work" },
],
}),
),
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
})
})
describe("connectionSummary", () => {
test("shows credential labels and environment variables", () => {
expect(
connectionSummary(
integration({
id: "example",
name: "Example",
connections: [
{ type: "credential", id: "cred_1", label: "Work" },
{ type: "env", name: "EXAMPLE_KEY" },
],
}),
),
).toBe("Work, $EXAMPLE_KEY")
})
})
@@ -83,6 +83,48 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
}
}
function stepStarted(id: string, sessionID = "session"): Event {
return {
id,
type: "session.next.step.started",
properties: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
}
}
function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
return {
id,
type: "session.next.step.ended",
properties: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
finish,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
}
}
function stepFailed(id: string, sessionID = "session"): Event {
return {
id,
type: "session.next.step.failed",
properties: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
error: { type: "unknown", message: "boom" },
},
}
}
const questionNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Question needs input",
@@ -139,21 +181,9 @@ describe("internal notifications TUI plugin", () => {
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit({
id: "event-2",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit(stepEnded("event-1"))
harness.emit(stepStarted("event-2"))
harness.emit(stepEnded("event-3"))
expect(harness.notifications).toEqual([
{
@@ -169,16 +199,8 @@ describe("internal notifications TUI plugin", () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
harness.emit({
id: "event-2",
type: "session.status",
properties: { sessionID: "subagent", status: { type: "busy" } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "subagent", status: { type: "idle" } },
})
harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent"))
expect(harness.notifications).toEqual([
{
@@ -199,21 +221,9 @@ describe("internal notifications TUI plugin", () => {
test("notifies session errors once and suppresses the following idle done notification", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
})
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit(stepStarted("event-1"))
harness.emit(stepFailed("event-2"))
harness.emit(stepEnded("event-3"))
expect(harness.notifications).toEqual([
{
@@ -228,21 +238,13 @@ describe("internal notifications TUI plugin", () => {
test("special-cases aborts and model response timeouts", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "abort", status: { type: "busy" } },
})
harness.emit(stepStarted("event-1", "abort"))
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "timeout", status: { type: "busy" } },
})
harness.emit(stepStarted("event-3", "timeout"))
harness.emit({
id: "event-4",
type: "session.error",
@@ -7,9 +7,9 @@ import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync"
import { ExitProvider } from "../../../../src/context/exit"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
@@ -22,8 +22,8 @@ export async function wait(fn: () => boolean, timeout = 2000) {
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
export async function mount(override?: FetchHandler, state?: string) {
const calls = createFetch(override)
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(override, events)
let sync!: ReturnType<typeof useSync>
let project!: ReturnType<typeof useProject>
let kv!: ReturnType<typeof useKV>
@@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) {
<TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider>
<KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<ExitProvider exit={() => {}}>
<SyncProvider>
@@ -1,262 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { tmpdir } from "../../../fixture/fixture"
import { json, mount, wait } from "./sync-fixture"
const sessionID = "ses_hydration_race"
const messageID = "msg_hydration_race"
const partID = "prt_hydration_race"
const session = {
id: sessionID,
title: "race",
time: { created: 0, updated: 0 },
version: "1.15.13",
directory: "/tmp/opencode/packages/opencode",
}
const assistant = {
id: messageID,
sessionID,
role: "assistant" as const,
agent: "build",
modelID: "model",
providerID: "test",
mode: "build",
parentID: "msg_user",
path: { cwd: session.directory, root: session.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
}
function global(payload: GlobalEvent["payload"]): GlobalEvent {
return { directory: "/tmp/other", project: "proj_test", payload }
}
test("stale session hydration does not overwrite live message parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 2,
part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
},
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
resolveMessages(
json([
{
info: assistant,
parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
},
]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
} finally {
app.renderer.destroy()
}
})
test("orphan live deltas do not suppress hydrated parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
}),
)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
} finally {
app.renderer.destroy()
}
})
test("hydration does not clear text streamed before it starts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 1,
part: { id: partID, sessionID, messageID, type: "text", text: "" },
},
}),
)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
} finally {
app.renderer.destroy()
}
})
test("live messages merged during hydration retain the 100 message window", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
const live = { ...assistant, id: "msg_z_live" }
emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
resolveMessages(
json(
Array.from({ length: 100 }, (_, index) => {
const id = `msg_${String(index).padStart(3, "0")}`
return {
info: { ...assistant, id },
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
}
}),
),
)
await hydrate
expect(sync.data.message[sessionID]).toHaveLength(100)
expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
expect(sync.data.part.msg_000).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("a message removed during hydration does not regain stale parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
await wait(() => sync.data.message[sessionID]?.length === 1)
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
await wait(() => sync.data.message[sessionID]?.length === 0)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
)
await hydrate
expect(sync.data.message[sessionID]).toEqual([])
expect(sync.data.part[messageID]).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
@@ -1,43 +0,0 @@
/** @jsxImportSource @opentui/solid */
/**
* Reproducer for #26560 TUI crashes with
* `TypeError: undefined is not an object (evaluating 'f.data.map')`
* when entering a session whose messages endpoint returns a non-2xx.
* The failure path is `sync.tsx#sync.session.sync` reading
* `messages.data!` while the SDK leaves `data` undefined on error.
*/
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../../../fixture/fixture"
import { directory, json, mount } from "./sync-fixture"
const sessionID = "ses_undef"
describe("tui sync (#26560)", () => {
test("entering a session whose messages endpoint errors does not crash sync", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const sessionPayload = {
id: sessionID,
title: "broken",
time: { created: 0, updated: 0 },
version: "1.14.42",
directory,
project_id: "proj_test",
}
const { app, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(sessionPayload)
if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 })
if (url.pathname === `/session/${sessionID}/todo`) return json([])
if (url.pathname === `/session/${sessionID}/diff`) return json([])
if (url.pathname === "/session") return json([sessionPayload])
return undefined
}, tmp.path)
try {
await expect(sync.session.sync(sessionID)).resolves.toBeUndefined()
} finally {
app.renderer.destroy()
}
})
})
+20 -61
View File
@@ -1,65 +1,24 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../../../fixture/fixture"
import { mount, wait } from "./sync-fixture"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { expect, test } from "bun:test"
import { mount } from "./sync-fixture"
function branchEvent(branch: string, workspace?: string): GlobalEvent {
return {
directory: "/tmp/other",
project: "proj_test",
workspace,
payload: {
id: `evt_vcs_${branch}`,
type: "vcs.branch.updated",
properties: { branch },
},
test("legacy sync is an inert compatibility context", async () => {
const { app, session, sync } = await mount()
try {
expect(sync.status).toBe("complete")
expect(sync.ready).toBe(true)
expect(sync.data.session).toEqual([])
expect(sync.data.message).toEqual({})
expect(sync.data.provider).toEqual([])
expect(sync.session.get("ses_test")).toBeUndefined()
await sync.bootstrap()
await sync.session.refresh()
await sync.session.sync("ses_test")
expect(session).toEqual([])
} finally {
app.renderer.destroy()
}
}
describe("tui sync", () => {
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, kv, sync, session } = await mount(undefined, tmp.path)
try {
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui")
kv.set("session_directory_filter_enabled", false)
await sync.session.refresh()
expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
expect(session.at(-1)?.searchParams.get("path")).toBeNull()
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
} finally {
app.renderer.destroy()
}
})
test("vcs branch updates only apply for the active workspace", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, emit, project, sync } = await mount(undefined, tmp.path)
try {
expect(sync.data.vcs?.branch).toBe("main")
project.workspace.set("ws_a")
emit(branchEvent("other", "ws_b"))
await Bun.sleep(30)
expect(sync.data.vcs?.branch).toBe("main")
emit(branchEvent("feature", "ws_a"))
await wait(() => sync.data.vcs?.branch === "feature")
expect(sync.data.vcs?.branch).toBe("feature")
} finally {
app.renderer.destroy()
}
})
})
@@ -3,7 +3,6 @@
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
@@ -14,12 +13,10 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read row
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
No LSP server available for this file type.
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
@@ -33,7 +30,6 @@ exports[`TUI inline tool wrapping keeps separation after a shell output block 1`
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
@@ -48,7 +44,6 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
@@ -76,7 +71,7 @@ exports[`TUI inline tool wrapping separates a task row from a preceding inline d
`;
exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = `
" Build · Little Frank · 53.1s
" Build · Little Frank · 53.1s
✓ Build Task — Review changes
↳ 48 toolcalls · 1m 40s"
+301 -23
View File
@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) {
@@ -21,12 +21,12 @@ function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
function emitEvent(events: ReturnType<typeof createEventStream>, payload: Event) {
events.emit(global(payload))
}
test("refreshes resources into reactive getters", async () => {
const events = createEventSource()
const events = createEventStream()
const location = {
directory,
project: { id: "proj_test", directory },
@@ -44,6 +44,14 @@ test("refreshes resources into reactive getters", async () => {
location: { directory },
},
})
if (url.pathname === "/api/session/ses_test/message")
return json({
data: [
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
],
cursor: {},
})
if (url.pathname === "/api/agent")
return json({
location,
@@ -60,12 +68,12 @@ test("refreshes resources into reactive getters", async () => {
function Probe() {
data = useData()
onMount(ready)
return <box />
return <text>{data.session.message.get("ses_test", "msg_second")?.id ?? "missing"}</text>
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@@ -77,14 +85,19 @@ test("refreshes resources into reactive getters", async () => {
try {
await mounted
expect(data.location.default()).toEqual({ directory })
expect(data.location.default()).toEqual({ directory: process.cwd() })
expect(data.session.get("ses_test")).toBeUndefined()
expect(data.location.agent.list(location)).toBeUndefined()
await data.session.refresh("ses_test")
await data.session.message.refresh("ses_test")
await data.location.agent.refresh()
expect(data.session.get("ses_test")?.title).toBe("Test session")
expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"])
expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("msg_second")
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
} finally {
@@ -92,8 +105,136 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { event: 0, model: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/event") {
requests.event++
return events.v2()
}
if (url.pathname !== "/api/model") return
requests.model++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{
id: `model-${requests.model}`,
providerID: "provider",
name: `Model ${requests.model}`,
api: { type: "native" },
capabilities: { tools: false, input: [], output: [] },
cost: [],
limit: { context: 1, output: 1 },
request: { headers: {}, body: {} },
status: "active",
time: { released: 0 },
variants: [],
},
],
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
events.disconnect()
await wait(() => data.connection.status() === "reconnecting")
expect(data.connection.attempt()).toBe(1)
expect(data.connection.error()).toBe("Event stream disconnected")
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
expect(requests.event).toBe(2)
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
expect(data.connection.error()).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active")
return json({ data: { "session-active": { type: "running" } } })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
emitEvent(events, {
id: "evt_step_started",
type: "session.next.step.started",
properties: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.status("session-live") === "running")
emitEvent(events, {
id: "evt_step_ended",
type: "session.next.step.ended",
properties: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 2,
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
await wait(() => data.session.status("session-live") === "idle")
} finally {
app.renderer.destroy()
}
})
test("refreshes integrations after integration updates", async () => {
const events = createEventSource()
const events = createEventStream()
const requests = { integration: 0, model: 0, provider: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/model") {
@@ -134,7 +275,7 @@ test("refreshes integrations after integration updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@@ -160,7 +301,7 @@ test("refreshes integrations after integration updates", async () => {
})
test("refreshes effective catalog data after catalog updates", async () => {
const events = createEventSource()
const events = createEventStream()
const requests = { model: 0, provider: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/model") {
@@ -175,7 +316,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<box />
@@ -196,7 +337,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
})
test("refreshes references after updates", async () => {
const events = createEventSource()
const events = createEventStream()
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/reference") return
@@ -220,7 +361,7 @@ test("refreshes references after updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@@ -241,8 +382,136 @@ test("refreshes references after updates", async () => {
}
})
test("adds and dismisses permission requests from live events", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_permission_asked_1",
type: "permission.v2.asked",
properties: {
id: "per_1",
sessionID: "ses_1",
action: "bash",
resources: ["bun test"],
},
})
emitEvent(events, {
id: "evt_permission_asked_2",
type: "permission.v2.asked",
properties: {
id: "per_2",
sessionID: "ses_1",
action: "read",
resources: [".env"],
},
})
await wait(() => data.session.permission.list("ses_1")?.length === 2)
emitEvent(events, {
id: "evt_permission_replied_1",
type: "permission.v2.replied",
properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
})
await wait(() => data.session.permission.list("ses_1")?.length === 1)
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
emitEvent(events, {
id: "evt_permission_replied_2",
type: "permission.v2.replied",
properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
})
await wait(() => data.session.permission.list("ses_1")?.length === 0)
} finally {
app.renderer.destroy()
}
})
test("adds and dismisses question requests from live events", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_question_asked_1",
type: "question.v2.asked",
properties: {
id: "que_1",
sessionID: "ses_1",
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
},
})
emitEvent(events, {
id: "evt_question_asked_2",
type: "question.v2.asked",
properties: {
id: "que_2",
sessionID: "ses_1",
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
},
})
await wait(() => data.session.question.list("ses_1")?.length === 2)
emitEvent(events, {
id: "evt_question_replied_1",
type: "question.v2.replied",
properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
})
await wait(() => data.session.question.list("ses_1")?.length === 1)
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
emitEvent(events, {
id: "evt_question_rejected_2",
type: "question.v2.rejected",
properties: { sessionID: "ses_1", requestID: "que_2" },
})
await wait(() => data.session.question.list("ses_1")?.length === 0)
} finally {
app.renderer.destroy()
}
})
test("settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@@ -258,7 +527,7 @@ test("settles pending tools when a live failure arrives", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@@ -334,7 +603,7 @@ test("settles pending tools when a live failure arrives", async () => {
})
await wait(() => {
const assistant = sync.session.message.list("session-1")?.[0]
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
@@ -342,7 +611,7 @@ test("settles pending tools when a live failure arrives", async () => {
)
})
const assistant = sync.session.message.list("session-1")?.[0]
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
@@ -360,10 +629,10 @@ test("settles pending tools when a live failure arrives", async () => {
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
"assistant",
"model-switched",
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"assistant",
])
} finally {
app.renderer.destroy()
@@ -371,7 +640,7 @@ test("settles pending tools when a live failure arrives", async () => {
})
test("renders admitted prompts only after they become model-visible", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@@ -387,7 +656,7 @@ test("renders admitted prompts only after they become model-visible", async () =
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@@ -399,6 +668,8 @@ test("renders admitted prompts only after they become model-visible", async () =
try {
await mounted
const received: string[] = []
const unsubscribe = sync.listen((event) => received.push(event.name))
emitEvent(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
@@ -425,17 +696,24 @@ test("renders admitted prompts only after they become model-visible", async () =
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
unsubscribe()
const message = sync.session.message.list("session-1")?.[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
expect(sync.session.message.ids("session-1")).toEqual(["msg_user_1"])
expect(sync.session.message.ids("missing")).toEqual([])
expect(sync.session.message.get("session-1", "msg_user_1")).toBe(message)
expect(sync.session.message.get("session-1", "missing")).toBeUndefined()
expect(received).toHaveLength(3)
} finally {
app.renderer.destroy()
}
})
test("projects live context updates with their message ID", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@@ -451,7 +729,7 @@ test("projects live context updates with their message ID", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createSignal, For, Show } from "solid-js"
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatCompletedSubagentDetail,
@@ -13,7 +12,6 @@ import {
parseQuestionAnswers,
parseQuestions,
parseTodos,
alwaysSeparate,
toolDisplay,
} from "../../../src/routes/session"
@@ -52,40 +50,10 @@ const tools: readonly ToolFixture[] = [
},
] as const
function ShellOutput() {
return (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
marginTop={1}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
gap={1}
>
<box gap={1}>
<text>$ ls</text>
<text>file.ts</text>
</box>
</box>
)
}
function UserMessage() {
return (
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
<text>Check whether the next tool remains separated.</text>
</box>
</box>
)
}
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
function Fixture(props: { errorExpanded?: boolean }) {
return (
<box flexDirection="column" width={72}>
<box flexDirection="column">
{props.before === "shell" && <ShellOutput />}
{props.before === "user" && <UserMessage />}
<For each={tools}>
{(item) => (
<InlineToolRow
@@ -105,94 +73,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
)
}
function TaskRowsFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow icon="✱" complete={true} pending="">
Grep "Task" (2 matches)
</InlineToolRow>
<InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
Explore Task Inspect active task spacing
</InlineToolRow>
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</box>
)
}
function LoadedReadBeforeTaskFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
<box paddingLeft={3}>
<text paddingLeft={3}> Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
</box>
)
}
function AssistantSummaryBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
<text> Build · Little Frank · 53.1s</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
</InlineToolRow>
</box>
)
}
function AssistantErrorBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
>
<text>Managed inference requires an active Member plan</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
</InlineToolRow>
</box>
)
}
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
return (
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
<box height={1}>
<text>First row</text>
</box>
<box height={1}>
<text>Second row</text>
</box>
<Show when={props.separated}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
<text>Assistant text</text>
</box>
</Show>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</scrollbox>
)
}
function FailedPendingToolFixture() {
return (
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
@@ -245,10 +125,18 @@ describe("TUI inline tool wrapping", () => {
parseApplyPatchFiles([
null,
{ type: "add" },
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
{ file: "a.ts", patch: "diff", additions: 1, deletions: 0, status: "added" },
]),
).toEqual([
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
{
type: "add",
relativePath: "a.ts",
filePath: "a.ts",
patch: "diff",
additions: 1,
deletions: 0,
movePath: undefined,
},
])
expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
{ status: "pending", content: "Safe" },
@@ -299,53 +187,4 @@ describe("TUI inline tool wrapping", () => {
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
})
test("keeps separation after a shell output block", async () => {
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
})
test("keeps separation after a padded user message", async () => {
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
})
test("separates after a multi-line task row", async () => {
expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
})
test("separates a task row from a preceding inline detail", async () => {
expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
})
test("separates an inline row from the previous assistant summary", async () => {
expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
})
test("separates an inline row from the previous assistant error", async () => {
expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { width: 72, height: 7 })).toMatchSnapshot()
})
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
const [separated, setSeparated] = createSignal(false)
let scroll: ScrollBoxRenderable | undefined
testSetup = await testRender(
() => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
{
width: 72,
height: 3,
},
)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(3)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
setSeparated(true)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(5)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
setSeparated(false)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(3)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
})
})
@@ -0,0 +1,105 @@
import { expect, test } from "bun:test"
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import { reduceSessionRows } from "../../../src/routes/session/rows"
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessage[] = [
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
assistant("assistant-1", [
{ type: "text", id: "text-1", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
]),
assistant("assistant-2", [
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
{ type: "text", id: "text-2", text: "Done" },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
{
type: "group",
kind: "exploration",
pending: [],
completed: true,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-1", partID: "glob-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
])
})
test("keeps non-exploration tools as individual part rows", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
{ type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } },
{
type: "group",
kind: "exploration",
pending: [],
completed: false,
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
},
])
})
test("groups across empty assistant reasoning parts", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
]),
assistant("assistant-2", [
{ type: "reasoning", id: "reasoning-2", text: "" },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
{
type: "group",
kind: "exploration",
pending: [],
completed: false,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
}
}
function pending() {
return { status: "pending" as const, input: "" }
}
+4 -4
View File
@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
const projectID = "proj_test"
@@ -49,8 +49,8 @@ function update(version: string): Event {
}
async function mount() {
const events = createEventSource()
const calls = createFetch()
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: Event[] = []
const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
@@ -61,7 +61,7 @@ async function mount() {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
@@ -1,46 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createDialogSessionListQuery, loadDialogSessionList } from "../../src/component/dialog-session-list"
describe("dialog session list", () => {
test("requests root sessions for the default browse list", () => {
expect(createDialogSessionListQuery({ filter: { path: "packages/tui" } })).toEqual({
roots: true,
limit: 100,
path: "packages/tui",
})
})
test("requests root sessions for search results", () => {
expect(createDialogSessionListQuery({ search: " deploy ", filter: { scope: "project" } })).toEqual({
roots: true,
limit: 30,
search: "deploy",
scope: "project",
})
})
test("keeps the cache usable while the root request is pending", async () => {
let resolve!: (result: { data: string[] }) => void
const pending = loadDialogSessionList<string>({
filter: {},
list: () => new Promise((done) => (resolve = done)),
})
expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending")
resolve({ data: ["root"] })
expect(await pending).toEqual(["root"])
})
test("falls back when the root request returns an error response", async () => {
expect(await loadDialogSessionList({ filter: {}, list: async () => ({}) })).toBeUndefined()
})
test("falls back when the root request rejects", async () => {
expect(
await loadDialogSessionList({
filter: {},
list: () => Promise.reject(new Error("offline")),
}),
).toBeUndefined()
})
})
+67 -42
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "../../src/context/sdk"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/tui`
@@ -11,64 +11,83 @@ export function json(data: unknown, init?: ResponseInit) {
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventStream() {
const encoder = new TextEncoder()
const global = new Set<ReadableStreamDefaultController<Uint8Array>>()
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
const pending = {
global: [] as Uint8Array[],
v2: [] as Uint8Array[],
}
const response = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
initial?: unknown,
) => {
let current: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
current = controller
controllers.add(controller)
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
},
cancel() {
if (current) controllers.delete(current)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
const send = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
event: unknown,
) => {
const chunk = encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
if (controllers.size === 0) {
queued.push(chunk)
return
}
for (const controller of controllers) controller.enqueue(chunk)
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
const pending: Uint8Array[] = []
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)
send(global, pending.global, event)
if (!("properties" in event.payload)) return
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
...event.payload,
location: { directory: event.directory, workspaceID: event.workspace },
data: event.payload.properties,
})}\n\n`,
)
if (stream) return stream.enqueue(chunk)
pending.push(chunk)
send(v2, pending.v2, {
...event.payload,
location: { directory: event.directory, workspaceID: event.workspace },
data: event.payload.properties,
})
},
response() {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
stream = controller
for (const chunk of pending.splice(0)) controller.enqueue(chunk)
},
cancel() {
stream = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
global() {
return response(global, pending.global)
},
v2() {
return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} })
},
disconnect() {
for (const controller of [...global, ...v2]) controller.close()
global.clear()
v2.clear()
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
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
if (url.pathname === "/api/event" && events) return events.response()
if (url.pathname === "/global/event" && events) return events.global()
if (url.pathname === "/api/event" && events) return events.v2()
if (
[
@@ -88,6 +107,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
url.pathname,
@@ -107,3 +128,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
}) as typeof globalThis.fetch
return { fetch, session }
}
export function createClient(fetch: typeof globalThis.fetch) {
return createOpencodeClient({ baseUrl: "http://test", fetch })
}
-3
View File
@@ -36,9 +36,6 @@ if (Script.release && !Script.preview) {
await prepareReleaseFiles()
console.log("\n=== cli ===\n")
await $`bun ./packages/opencode/script/publish.ts`
console.log("\n=== preview cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== sdk ===\n")
-5
View File
@@ -8,11 +8,6 @@
"dependsOn": [],
"outputs": ["dist/**"]
},
"opencode#test": {
"dependsOn": ["^build"],
"outputs": [],
"passThroughEnv": ["*"]
},
"@opencode-ai/core#test": {
"dependsOn": ["^build"],
"outputs": []