mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1237af0e1 | ||
|
|
ecbe2fa7ea | ||
|
|
66f326ddec | ||
|
|
83b81b39f1 | ||
|
|
b20e4971db | ||
|
|
c3378f2d34 | ||
|
|
d68031bc0d | ||
|
|
4ac19d744a | ||
|
|
b5cb5e2a55 | ||
|
|
53d171781a | ||
|
|
3184427c03 | ||
|
|
6b0613ac6f | ||
|
|
5ac4156eb2 | ||
|
|
954cdc7bc8 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
"@opencode-ai/server": patch
|
||||
---
|
||||
|
||||
Centralize application construction in Core, isolate plugin runtime bindings per application, and preserve interruption options through the plugin bridge.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/util": patch
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Validate graph replacement outputs and shared dependency conflicts before building services. Resolve dependencies against the final override map and automatically bind Location maps introduced by replacements.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
|
||||
@@ -237,9 +237,9 @@ jobs:
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests
|
||||
- name: Run app e2e tests against production build
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
run: bun --cwd packages/app test:e2e:built
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
@@ -364,7 +364,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -2175,19 +2175,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.11", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.11", "@opencode-ai/pty-darwin-x64": "0.1.11", "@opencode-ai/pty-linux-arm64-gnu": "0.1.11", "@opencode-ai/pty-linux-arm64-musl": "0.1.11", "@opencode-ai/pty-linux-x64-gnu": "0.1.11", "@opencode-ai/pty-linux-x64-musl": "0.1.11" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-Q4p0XXZWbc8FnpEJaaLqVbCdodxR9lVzaQjMH18KvjX/4m6tYfuspz03mvkN9MdmtDJ2GOZS7QgGQ7Q+RE9aWw=="],
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hz59ImecqeBdLQ40TknPDc9k4xWjQPaTgZ7cXzF3vclAvZiFYSM1rRdbBF6rOaOB0DBp0OFYsiaPP1ykszLsfw=="],
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TPpA+FZ08BXtTcOeqe0FEJitqLld6Nl46UizcSmQCTRM22xOKPar6OoxHGYtqdFCaFk43a+hk/0GWpV7mcEQEg=="],
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PTU9Ss5a5pApw6IeVvjjbFPuui2oKMoTQ/nY+K1+idLpgMeQHXk2URJbQqetqJxH4OLL5eSutDeWvpkweW0tTw=="],
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-NFZ2LLfEaO6858cYtEwfQKda/HnCrPR0WflnWoDllHmdY12umeHkeE8DnZQK48tziXxvEACspItXmleiAMAg3g=="],
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-2Wbko2tFkgTmY6ceB+QMA3E+omaRd5IBBFjJoikMxkc7n75TXYQVz09tCC0pPW+flGvApFQ2YcIkSotdLwit+A=="],
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-PF7vbOsSOVbRSo11pOOmJq/Vp34Ww7Xoo8rUeMSAoG1tJSIp2SXFHCRKEGH9H4KxGUfpRHQlDBV1HLz8onnyJQ=="],
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
|
||||
+28
-4
@@ -31,7 +31,7 @@ Your app is ready to be deployed!
|
||||
|
||||
## E2E Testing
|
||||
|
||||
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
|
||||
Locally, Playwright starts the Vite dev server automatically via `webServer`, or reuses one already running at the configured address. The browser suite uses isolated API fixtures rather than a live opencode backend.
|
||||
|
||||
```bash
|
||||
bunx playwright install chromium
|
||||
@@ -39,11 +39,35 @@ bun run test:e2e:local
|
||||
bun run test:e2e:local -- --grep "settings"
|
||||
```
|
||||
|
||||
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
|
||||
|
||||
```bash
|
||||
bun run test:e2e:built
|
||||
bun run test:e2e:built -- --grep "settings"
|
||||
```
|
||||
|
||||
To test an already-running dev server without starting or building a server:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
|
||||
```
|
||||
|
||||
For an already-running production build, also set `PLAYWRIGHT_BUILD=1` so the fixture API uses the app's origin:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
|
||||
```
|
||||
|
||||
External targets must use HTTP because fixture URLs use HTTP. `PLAYWRIGHT_BASE_URL` skips server startup and building in either mode.
|
||||
|
||||
Compiled CLI startup and service lifecycle coverage runs separately in CI via `packages/cli/script/service-smoke.ts`.
|
||||
|
||||
Environment options:
|
||||
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
|
||||
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
|
||||
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
|
||||
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ The suite contains:
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
- retained renderer heap with a large model catalog across repeated session navigation
|
||||
|
||||
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
|
||||
|
||||
@@ -78,6 +79,17 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
|
||||
|
||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||
|
||||
## Retained renderer memory
|
||||
|
||||
Run the catalog workload against the production app bundle:
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/provider-memory-benchmark.spec.ts --repeat-each=3
|
||||
```
|
||||
|
||||
`PROVIDER_MEMORY_MODELS` defaults to 1,200 and `PROVIDER_MEMORY_SWITCHES` defaults to 10. Each sample records Chromium's `Runtime.getHeapUsage` and `Memory.getDOMCounters` after an explicit garbage collection. This measures retained state, not allocation peaks or normal GC timing. It does not include worker heaps, the Electron main/GPU processes, or the OpenCode server, and must not be reported as total desktop RAM. Use identical model counts and navigation sequences for before/after comparisons.
|
||||
|
||||
## Chrome traces
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import config from "../../playwright.config"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = new URL(process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`).port || "80"
|
||||
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
||||
|
||||
export default {
|
||||
@@ -12,9 +12,11 @@ export default {
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
...config.webServer,
|
||||
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
webServer: config.webServer
|
||||
? {
|
||||
...config.webServer,
|
||||
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
||||
reuseExistingServer: false,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import type { CatalogUpdated } from "@opencode-ai/client/promise"
|
||||
|
||||
benchmark("measures retained renderer memory with a large model catalog", async ({ page, report }) => {
|
||||
benchmark.setTimeout(120_000)
|
||||
const count = Number(process.env.PROVIDER_MEMORY_MODELS ?? 1200)
|
||||
const switches = Number(process.env.PROVIDER_MEMORY_SWITCHES ?? 10)
|
||||
const provider = fixture.provider.all[0]
|
||||
const selected = { ...provider.models["claude-opus-4-6"] }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages,
|
||||
provider: {
|
||||
...fixture.provider,
|
||||
all: [
|
||||
{
|
||||
...provider,
|
||||
models: {
|
||||
[selected.id]: selected,
|
||||
...Object.fromEntries(
|
||||
Array.from({ length: count - 1 }, (_, index) => {
|
||||
const id = `catalog-model-${index}`
|
||||
return [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name: `Catalog model ${index}`,
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 200_000, output: 8192 },
|
||||
variants: { high: { reasoningEffort: "high" } },
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
const samples = []
|
||||
for (let index = 0; index <= switches; index++) {
|
||||
if (index > 0) {
|
||||
const target = index % 2 === 1
|
||||
const id = target ? fixture.targetID : fixture.sourceID
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`).click()
|
||||
await expectSessionTitle(page, target ? fixture.expected.targetTitle : fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(
|
||||
page,
|
||||
(target ? fixture.expected.targetMessageIDs : fixture.expected.sourceMessageIDs).at(-1)!,
|
||||
)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
}
|
||||
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
|
||||
await cdp.send("HeapProfiler.collectGarbage")
|
||||
samples.push({
|
||||
switches: index,
|
||||
heap: await cdp.send("Runtime.getHeapUsage"),
|
||||
dom: await cdp.send("Memory.getDOMCounters"),
|
||||
})
|
||||
}
|
||||
expect(samples).toHaveLength(switches + 1)
|
||||
expect(samples.every((sample) => sample.heap.usedSize > 0)).toBe(true)
|
||||
selected.name = "Updated catalog model"
|
||||
await page.evaluate(
|
||||
(event) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: CatalogUpdated[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([event])
|
||||
},
|
||||
{
|
||||
id: "evt_catalog_refresh",
|
||||
created: Date.now(),
|
||||
type: "catalog.updated",
|
||||
location: { directory: fixture.directory },
|
||||
data: {},
|
||||
} satisfies CatalogUpdated,
|
||||
)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText(selected.name)
|
||||
report(
|
||||
{ samples },
|
||||
{ models: count, switches, gc: "explicit", scope: "renderer main isolate; not total desktop RAM" },
|
||||
)
|
||||
await cdp.detach()
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
@@ -12,17 +12,17 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
const requests: string[] = []
|
||||
await mockServers(page, requests)
|
||||
await page.addInitScript(
|
||||
({ serverB, sessionA, sessionB }) => {
|
||||
({ serverA, serverB, sessionA, sessionB }) => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ type: "session", server: serverA, sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
@@ -55,7 +55,7 @@ function session(id: string, directory: string, title: string) {
|
||||
async function mockServers(page: Page, requests: string[]) {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await page.route("**/*", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
requests.push(url.toString())
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
@@ -308,7 +308,7 @@ async function mockServers(
|
||||
permissionResponses: PermissionResponse[] = [],
|
||||
options: MockServerOptions = {},
|
||||
) {
|
||||
await page.route("**/*", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const remote = url.origin === serverB
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
@@ -51,7 +51,7 @@ function session(id: string, directory: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
await page.route("**/*", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
@@ -238,7 +238,7 @@ function session(id: string, title: string) {
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
const sessions = [sessionA, sessionB, sessionC]
|
||||
await page.route("**/*", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||
|
||||
@@ -144,12 +144,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||
// Production serves the UI and API from one origin; leave app assets to Vite.
|
||||
if (!url.pathname.startsWith("/api/")) return route.fallback()
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
return route.fulfill({ status: 204, headers: corsHeaders })
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:components": "playwright test --config playwright.components.config.ts",
|
||||
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
|
||||
|
||||
@@ -2,10 +2,19 @@ import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
|
||||
const url = new URL(baseURL)
|
||||
if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// app URL")
|
||||
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
|
||||
// Production connects to its own origin, so fixture URLs must match the preview server.
|
||||
if (built) {
|
||||
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
|
||||
}
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const command = built
|
||||
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
|
||||
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -23,16 +32,18 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
reuseExistingServer: reuse,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !built,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Home() {
|
||||
return (
|
||||
<div
|
||||
class={`
|
||||
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { unwrap } from "solid-js/store"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
const providerCatalogs = new WeakMap<
|
||||
ProviderListOutput["data"],
|
||||
WeakMap<ModelListOutput["data"], ProviderListResponse>
|
||||
>()
|
||||
|
||||
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||
@@ -30,12 +36,16 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
|
||||
}
|
||||
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
input: ProviderListOutput["data"] | ProviderListResponse,
|
||||
catalog?: ModelListOutput["data"],
|
||||
): ProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return providers
|
||||
}
|
||||
if (!Array.isArray(input)) return input
|
||||
// Client sync replaces whole catalog lists. Track those reads at the caller,
|
||||
// not every model field, and share conversions without retaining old lists.
|
||||
const providers = unwrap(input)
|
||||
const models = unwrap(catalog)
|
||||
const cached = models && providerCatalogs.get(providers)?.get(models)
|
||||
if (cached) return cached
|
||||
const all = new Map<string, Provider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
@@ -101,7 +111,7 @@ export function normalizeProviderList(
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
all,
|
||||
connected: providers.map((provider) => provider.id),
|
||||
default: Object.fromEntries(
|
||||
@@ -111,6 +121,12 @@ export function normalizeProviderList(
|
||||
}),
|
||||
),
|
||||
}
|
||||
if (models) {
|
||||
const cache = providerCatalogs.get(providers) ?? new WeakMap<ModelListOutput["data"], ProviderListResponse>()
|
||||
cache.set(models, result)
|
||||
providerCatalogs.set(providers, cache)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
|
||||
@@ -44,9 +44,7 @@ function TargetSessionSettingsCommand() {
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
|
||||
) {
|
||||
function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
@@ -106,7 +104,7 @@ function PendingSessionState(props: { sessionID: string }) {
|
||||
|
||||
function SessionStatePanel(props: ParentProps) {
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 p-2">
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -164,7 +164,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return (
|
||||
<>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ParentProps } from "solid-js"
|
||||
|
||||
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
|
||||
<div
|
||||
class="relative flex size-full flex-col overflow-hidden"
|
||||
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -481,7 +481,6 @@ function MessageTimelineView(
|
||||
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
|
||||
@@ -55,7 +55,6 @@ type Input = {
|
||||
type ViewProps = {
|
||||
header: JSX.Element
|
||||
bottomSpacer?: JSX.Element
|
||||
workspaceSession: Accessor<boolean>
|
||||
deferred: (row: TimelineRow.TimelineRow) => boolean
|
||||
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
|
||||
}
|
||||
@@ -371,7 +370,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
|
||||
@@ -36,7 +36,7 @@ export function AppRoutes() {
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 p-2">
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ export default function Layout(props: ParentProps) {
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
// The native Windows titlebar already includes the gap above the content panels.
|
||||
"--shell-top-inset":
|
||||
platform.platform === "desktop" &&
|
||||
platform.os === "windows" &&
|
||||
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
|
||||
? "0px"
|
||||
: "8px",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
@@ -59,7 +66,7 @@ export default function Layout(props: ParentProps) {
|
||||
<aside
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
data-slot="vertical-tabs-sidebar"
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
|
||||
style={{ width: `${state.tabsWidth}px` }}
|
||||
>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -27,6 +27,7 @@ import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
|
||||
|
||||
const titlebarHeight = 36
|
||||
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
@@ -59,7 +60,7 @@ export function Titlebar(props: {
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const minHeight = () => {
|
||||
if (mac()) return `${titlebarHeight / zoom()}px`
|
||||
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
|
||||
if (windows()) return `env(titlebar-area-height, ${windowsTitlebarHeight / Math.min(titlebarZoom(), 1)}px)`
|
||||
return undefined
|
||||
}
|
||||
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
test.each([
|
||||
{ name: "local development", ci: "", build: "", built: false },
|
||||
{ name: "local production build", ci: "", build: "1", built: true },
|
||||
{ name: "CI production build", ci: "true", build: "", built: true },
|
||||
{ name: "CI cannot opt into development", ci: "true", build: "0", built: true },
|
||||
])("Playwright uses $name", ({ ci, build, built }) => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, "-e", 'import config from "./playwright.config.ts"; console.log(JSON.stringify(config))'],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
CI: ci,
|
||||
PLAYWRIGHT_BUILD: build,
|
||||
PLAYWRIGHT_BASE_URL: undefined,
|
||||
PLAYWRIGHT_PORT: "4321",
|
||||
PLAYWRIGHT_SERVER_HOST: "127.0.0.2",
|
||||
PLAYWRIGHT_SERVER_PORT: "4322",
|
||||
},
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
const config = JSON.parse(result.stdout.toString())
|
||||
expect(config.use.baseURL).toBe("http://127.0.0.1:4321")
|
||||
expect(config.webServer.url).toBe(config.use.baseURL)
|
||||
expect(config.webServer.command).toBe(
|
||||
built
|
||||
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
|
||||
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
|
||||
)
|
||||
expect(config.webServer.reuseExistingServer).toBe(!built)
|
||||
expect(config.webServer.env).toEqual({
|
||||
VITE_OPENCODE_SERVER_HOST: built ? "127.0.0.1" : "127.0.0.2",
|
||||
VITE_OPENCODE_SERVER_PORT: built ? "4321" : "4322",
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"./playwright.config.ts",
|
||||
"./e2e/performance/playwright.config.ts",
|
||||
"./e2e/performance/playwright.uncapped.config.ts",
|
||||
"./e2e/performance/timeline-stability/playwright.config.ts",
|
||||
])("%s leaves an explicit external app unmanaged", (file) => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
`import config from ${JSON.stringify(file)}; console.log(JSON.stringify({ ...config, fixtureHost: process.env.PLAYWRIGHT_SERVER_HOST, fixturePort: process.env.PLAYWRIGHT_SERVER_PORT }))`,
|
||||
],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "http://127.0.0.1:4444" },
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
const config = JSON.parse(result.stdout.toString())
|
||||
expect(config.webServer).toBeUndefined()
|
||||
expect(config.use.baseURL).toBe("http://127.0.0.1:4444")
|
||||
expect(config.fixtureHost).toBe("127.0.0.1")
|
||||
expect(config.fixturePort).toBe("4444")
|
||||
})
|
||||
|
||||
test("Playwright rejects HTTPS targets unsupported by the API fixtures", () => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, "-e", 'import "./playwright.config.ts"'],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "https://e2e.example.com" },
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.toString()).toContain("E2E fixtures require an http:// app URL")
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
test("preserves an already normalized reactive catalog", () => {
|
||||
const [store] = createStore({ catalog: { all: new Map<string, never>(), connected: [], default: {} } })
|
||||
expect(normalizeProviderList(store.catalog)).toBe(store.catalog)
|
||||
})
|
||||
|
||||
test("shares catalog snapshots and reacts to replacement lists", () => {
|
||||
createRoot((dispose) => {
|
||||
const provider = { id: "openai", name: "OpenAI", package: "@ai-sdk/openai", activation: "enabled" as const }
|
||||
const model = {
|
||||
id: "gpt-5",
|
||||
modelID: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "GPT-5",
|
||||
settings: {},
|
||||
headers: {},
|
||||
capabilities: { tools: true, input: ["text" as const], output: ["text" as const] },
|
||||
variants: [],
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active" as const,
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8192 },
|
||||
}
|
||||
const [store, setStore] = createStore({ providers: [provider], models: [model] })
|
||||
const first = createMemo(() => normalizeProviderList(store.providers, store.models))
|
||||
const second = createMemo(() => normalizeProviderList(store.providers, store.models))
|
||||
expect(first()).toBe(second())
|
||||
const initial = first()
|
||||
setStore("models", [{ ...model, name: "Renamed", limit: { context: 256_000, output: 16384 } }])
|
||||
expect(first()).not.toBe(initial)
|
||||
expect(first()).toBe(second())
|
||||
expect(first().all.get("openai")?.models[model.id]).toMatchObject({
|
||||
name: "Renamed",
|
||||
limit: { context: 256_000, output: 16384 },
|
||||
})
|
||||
const renamed = first()
|
||||
setStore("providers", [{ ...provider, name: "Custom OpenAI" }])
|
||||
expect(first()).not.toBe(renamed)
|
||||
expect(first()).toBe(second())
|
||||
expect(first().all.get("openai")?.name).toBe("Custom OpenAI")
|
||||
expect(normalizeProviderList(store.providers).all.get("openai")?.models).toEqual({})
|
||||
setStore("models", [])
|
||||
expect(first().all.get("openai")?.models).toEqual({})
|
||||
expect(first().default).toEqual({})
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
export * as Application from "./application.js"
|
||||
export { Options } from "./application/options.js"
|
||||
|
||||
import { Effect, Layer } from "effect"
|
||||
import type { Options } from "./application/options.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import type { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { App } from "./app.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { AppNodeBuilder } from "./effect/app-node-builder.js"
|
||||
import { EventLogger } from "./event-logger.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
import { Job } from "./job.js"
|
||||
import { LocationActivity } from "./location-activity.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { ModelsDev } from "./models-dev.js"
|
||||
import { PermissionSaved } from "./permission/saved.js"
|
||||
import { PersistentPty } from "./persistent-pty.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
import { SdkPlugins } from "./plugin/sdk.js"
|
||||
import { Project } from "./project.js"
|
||||
import { PtyTicket } from "./pty/ticket.js"
|
||||
import { Session } from "./session.js"
|
||||
import { SessionRestart } from "./session/execution/restart.js"
|
||||
import { SessionTransfer } from "./session/transfer.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { WellKnown } from "./wellknown.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
|
||||
const services = LayerNode.group([
|
||||
Global.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
EventLogger.node,
|
||||
httpClient,
|
||||
Job.node,
|
||||
Project.node,
|
||||
Worktree.node,
|
||||
Session.node,
|
||||
SessionTransfer.node,
|
||||
SdkPlugins.node,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
PersistentPty.node,
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
LocationServiceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
Workspace.node,
|
||||
])
|
||||
|
||||
/** Build the standard application without choosing an HTTP or process host. */
|
||||
export function layer<A = never, E = never>(
|
||||
options: Options = {},
|
||||
overrides: LayerNode.Replacements = [],
|
||||
extra?: Node.GlobalNode<A, E>,
|
||||
) {
|
||||
return build(LayerNode.group([services, ...(extra ? [extra] : [])]), [
|
||||
[Database.node, Database.configured(options.database)],
|
||||
[Bus.node, Bus.configured({ persist: options.events?.persist })],
|
||||
[App.node, App.configured(options.app)],
|
||||
[ModelsDev.node, ModelsDev.configured(options.models)],
|
||||
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
|
||||
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
|
||||
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
|
||||
[Config.node, Config.configured(options.config)],
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
|
||||
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
|
||||
[
|
||||
MCP.node,
|
||||
MCP.configured({
|
||||
clientInfo: { name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown" },
|
||||
}),
|
||||
],
|
||||
...overrides,
|
||||
])
|
||||
}
|
||||
|
||||
/** Own the global-to-Location runtime connection, including for focused application fixtures. */
|
||||
export function build<A, E>(root: Node.GlobalNode<A, E>, overrides: LayerNode.Replacements = []) {
|
||||
return Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Effect.scope
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const cell = PluginRuntime.makeCell()
|
||||
// Location factories must capture this same map, not an enclosing host's map.
|
||||
return yield* Layer.buildWithMemoMap(
|
||||
AppNodeBuilder.build(LayerNode.group([root, PluginRuntime.providerNode]), [
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)],
|
||||
...overrides,
|
||||
]),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export * as ApplicationOptions from "./options.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
app: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
channel: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
database: Schema.optional(Database.Options),
|
||||
events: Schema.optional(Schema.Struct({ persist: Schema.optional(Schema.Boolean) })),
|
||||
models: Schema.optional(ModelsDev.Options),
|
||||
config: Schema.optional(
|
||||
Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
file: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
windows: Schema.optional(Schema.Struct({ gitbash: Schema.optional(Schema.String) })),
|
||||
fs: Schema.optional(
|
||||
Schema.Struct({
|
||||
filewatcher: Schema.optional(Schema.Boolean),
|
||||
fff: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
@@ -5,16 +5,11 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||
// Only build the location service map if it's actually needed
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
|
||||
return LayerNode.compile(root, replacements)
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node, replacements)) return LayerNode.compile(root, replacements)
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacements)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
|
||||
}
|
||||
|
||||
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
|
||||
return replacements.some(([source]) => source.name === node.name)
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder.js"
|
||||
|
||||
@@ -507,9 +507,11 @@ const layer = Layer.effect(
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
|
||||
return {
|
||||
app,
|
||||
location: locationInfo(),
|
||||
options: {},
|
||||
agent: {
|
||||
get: (input) => {
|
||||
@@ -288,26 +289,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
|
||||
return response(mcp.servers())
|
||||
},
|
||||
add: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
|
||||
return mcp.add(input.server, input.config)
|
||||
},
|
||||
remove: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
|
||||
return mcp.remove(input.server)
|
||||
},
|
||||
connect: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
|
||||
return mcp.connect(input.server)
|
||||
},
|
||||
disconnect: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
|
||||
return mcp.disconnect(input.server)
|
||||
},
|
||||
reload: mcp.reload,
|
||||
transform: (callback) =>
|
||||
mcp.transform((draft) => {
|
||||
@@ -377,8 +358,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
transform: (callback) =>
|
||||
tools
|
||||
.transform((draft) =>
|
||||
callback({
|
||||
add: (tool) => draft.add(tool),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.as({ dispose: Effect.void })),
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as PluginRuntime from "./runtime.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
@@ -40,10 +39,6 @@ export interface Interface {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
|
||||
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
|
||||
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +76,7 @@ export const layerWithCell = (cell: Cell) =>
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
|
||||
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
interrupt: (sessionID, options) => require(cell, (runtime) => runtime.session.interrupt(sessionID, options)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
|
||||
@@ -101,10 +96,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
|
||||
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
|
||||
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
|
||||
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
|
||||
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -149,14 +140,6 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
data: yield* mcp.servers(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
add: (ref, server, config) =>
|
||||
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
|
||||
remove: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
connect: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
disconnect: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
// Let the destination adapter handle readable reasoning after a model/provider switch.
|
||||
if (item.type === "reasoning")
|
||||
return reuseProviderMetadata
|
||||
? [
|
||||
@@ -167,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
? [{ type: message.error === undefined ? "reasoning" : "text", text: item.text }]
|
||||
: []
|
||||
// Call-side metadata is model-scoped proof of generation (Gemini thought
|
||||
// signatures, OpenAI encrypted reasoning): only the producing model may
|
||||
|
||||
+136
-132
@@ -4,8 +4,7 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
|
||||
|
||||
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { ToolDraft } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
|
||||
import { Context, Effect, Layer, Schema, SchemaIssue, Scope, Semaphore } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Agent } from "./agent.js"
|
||||
import { CodeModeCatalog } from "./codemode/catalog.js"
|
||||
@@ -15,7 +14,6 @@ import { Permission } from "./permission.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { State } from "./state.js"
|
||||
import { definition, execute, normalizeContent } from "./tool/runtime.js"
|
||||
import { Wildcard } from "./util/wildcard.js"
|
||||
|
||||
@@ -24,7 +22,10 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface extends State.Transformable<ToolDraft> {
|
||||
export interface Interface {
|
||||
readonly transform: (
|
||||
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -78,6 +79,9 @@ const layer = Layer.effect(
|
||||
]
|
||||
})
|
||||
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly tool: Tool.Info }>>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const executeTool = Effect.fn("Tool.execute")(function* (
|
||||
tool: Tool.Info,
|
||||
name: string,
|
||||
@@ -133,123 +137,112 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const state = State.create({
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
tools: new Map<string, Types.Mutable<Tool.Info>>(),
|
||||
errors: new Array<{ tool: Tool.Info; error: RegistrationError }>(),
|
||||
}),
|
||||
draft: (data) => data,
|
||||
finalize: (draft) =>
|
||||
Effect.forEach(
|
||||
draft.errors,
|
||||
(entry) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: entry.tool.name,
|
||||
namespace: entry.tool.options?.namespace,
|
||||
error: entry.error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
|
||||
const tools: Array<Tool.Info> = []
|
||||
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
|
||||
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
|
||||
yield* validateName(normalizedName(entry.tool))
|
||||
if (entry.tool.options?.codemode === false && entry.key === "execute")
|
||||
return yield* new RegistrationError({
|
||||
name: entry.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
})
|
||||
yield* Effect.try({
|
||||
try: () => ToolDefinition.make(definition(entry.tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({
|
||||
name: entry.key,
|
||||
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
|
||||
}),
|
||||
})
|
||||
return true
|
||||
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
|
||||
)
|
||||
// Reject every ambiguous entry rather than choosing a winner.
|
||||
const entries = yield* Effect.filter(valid, (entry) => {
|
||||
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
|
||||
return skipRegistration(
|
||||
entry.tool,
|
||||
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
|
||||
)
|
||||
})
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
lock.withPermit(
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(entry.key, remaining)
|
||||
else local.delete(entry.key)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: (callback) =>
|
||||
state.transform((draft) => {
|
||||
// Preserve rejection of ambiguous adds within one transform, without rejecting later overrides.
|
||||
const added = new Map<string, Tool.Info | undefined>()
|
||||
callback({
|
||||
add: (tool) => {
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
const id = effectiveName(tool)
|
||||
if (added.has(id)) {
|
||||
draft.errors.push({
|
||||
tool,
|
||||
error: new RegistrationError({ name: id, message: `Duplicate normalized tool name: ${id}` }),
|
||||
})
|
||||
const previous = added.get(id)
|
||||
if (previous) {
|
||||
draft.tools.set(id, previous)
|
||||
return
|
||||
}
|
||||
draft.tools.delete(id)
|
||||
return
|
||||
}
|
||||
added.set(id, draft.tools.get(id))
|
||||
draft.tools.set(id, { ...tool })
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.tools.get(id)
|
||||
if (!current) return
|
||||
const tool = { ...current }
|
||||
update(tool)
|
||||
tool.name = current.name
|
||||
if (tool.options?.namespace !== current.options?.namespace)
|
||||
tool.options = { ...tool.options, namespace: current.options?.namespace }
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
draft.tools.set(id, tool)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.tools.delete(id)
|
||||
added.delete(id)
|
||||
},
|
||||
})
|
||||
}),
|
||||
reload: state.reload,
|
||||
transform,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const tool = entries.at(-1)?.tool
|
||||
if (!tool) continue
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
@@ -267,22 +260,27 @@ function schemaMakeError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function registrationError(tool: Tool.Info) {
|
||||
const namespace = tool.options?.namespace
|
||||
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
|
||||
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
|
||||
const name = normalizedName(tool)
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
|
||||
const id = effectiveName(tool)
|
||||
if (tool.options?.codemode === false && id === "execute")
|
||||
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
|
||||
const result = Result.try({
|
||||
try: () => ToolDefinition.make(definition(tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }),
|
||||
})
|
||||
return Result.isFailure(result) ? result.failure : undefined
|
||||
}
|
||||
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(false))
|
||||
|
||||
const validateName = (name: string) =>
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(name)
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
const validateNamespace = (namespace: string) =>
|
||||
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new RegistrationError({
|
||||
name: namespace,
|
||||
message: `Invalid tool namespace: ${JSON.stringify(namespace)}`,
|
||||
}),
|
||||
)
|
||||
|
||||
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
@@ -291,6 +289,12 @@ const effectiveName = (tool: Tool.Info) =>
|
||||
? normalizedName(tool)
|
||||
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
|
||||
|
||||
const normalizedEntries = (tools: ReadonlyArray<Tool.Info>) =>
|
||||
tools.map((tool) => ({
|
||||
key: effectiveName(tool),
|
||||
tool,
|
||||
}))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -30,18 +30,17 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
- Tool transforms use the shared `State.create` lifecycle, like agents and skills: `add`, `update`, and `remove` replay in registration order when state is rebuilt.
|
||||
- `update` and `remove` do nothing for missing tools. `add` requires a complete tool definition.
|
||||
- Disposing a registration or closing its scope removes its transform and rebuilds the remaining state. `reload` replays transforms after their external inputs change.
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
|
||||
|
||||
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
|
||||
|
||||
`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -57,4 +56,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- Future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -30,88 +30,94 @@ export const layer = Layer.effect(
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: MCP.Tool[] = []
|
||||
let current: Scope.Closeable | undefined
|
||||
|
||||
// Keep the source's position so later plugin transforms also apply after MCP refreshes.
|
||||
yield* tools.transform((draft) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
draft.add({
|
||||
name: tool.name,
|
||||
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
// Register the current tool set under a fresh child scope, then close the previous one so the
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.reload()
|
||||
const discovered = yield* mcp.tools()
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools
|
||||
.transform((draft) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
draft.add({
|
||||
name: tool.name,
|
||||
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
.pipe(Scope.provide(next))
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Application } from "@opencode-ai/core/application"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const options = {
|
||||
database: { path: ":memory:" },
|
||||
config: { project: false, content: JSON.stringify({ plugins: ["-opencode.*"] }) },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false, fff: false },
|
||||
} satisfies Application.Options
|
||||
|
||||
describe("Application", () => {
|
||||
it.live("shares the application's database across isolated Locations", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "second")))
|
||||
const observed: Database.Service["Service"][] = []
|
||||
const supervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
observed.push(database)
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [Database.node],
|
||||
})
|
||||
const context = yield* Layer.build(
|
||||
Application.layer(options, [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[PluginSupervisor.node, supervisor],
|
||||
]),
|
||||
)
|
||||
const locations = Context.get(context, LocationServiceMap.Service)
|
||||
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
|
||||
const first = yield* locations.contextEffect(firstRef)
|
||||
const again = yield* locations.contextEffect(firstRef)
|
||||
const second = yield* locations.contextEffect(
|
||||
Location.Ref.make({ directory: AbsolutePath.make(path.join(directory.path, "second")) }),
|
||||
)
|
||||
|
||||
expect(observed).toHaveLength(2)
|
||||
expect(observed.every((database) => database === Context.get(context, Database.Service))).toBe(true)
|
||||
expect(Context.get(first, Tool.Service)).toBe(Context.get(again, Tool.Service))
|
||||
expect(Context.get(first, Tool.Service)).not.toBe(Context.get(second, Tool.Service))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates repeated application builds and binds plugins to their owning sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
|
||||
)
|
||||
const scope = yield* Effect.scope
|
||||
const firstScope = yield* Scope.fork(scope)
|
||||
const secondScope = yield* Scope.fork(scope)
|
||||
const application = Application.layer(options, [[Global.node, tempGlobalLayer]])
|
||||
const first = yield* Layer.build(application).pipe(Scope.provide(firstScope))
|
||||
const second = yield* Layer.build(application).pipe(Scope.provide(secondScope))
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
|
||||
const firstReady = yield* Deferred.make<Plugin.Context>()
|
||||
const secondReady = yield* Deferred.make<Plugin.Context>()
|
||||
yield* Context.get(first, SdkPlugins.Service).register({
|
||||
id: "application-probe",
|
||||
effect: (context) => Deferred.succeed(firstReady, context),
|
||||
})
|
||||
yield* Context.get(second, SdkPlugins.Service).register({
|
||||
id: "application-probe",
|
||||
effect: (context) => Deferred.succeed(secondReady, context),
|
||||
})
|
||||
yield* Context.get(first, LocationServiceMap.Service).contextEffect(ref).pipe(Scope.provide(firstScope))
|
||||
yield* Context.get(second, LocationServiceMap.Service).contextEffect(ref).pipe(Scope.provide(secondScope))
|
||||
const firstPlugin = yield* Deferred.await(firstReady).pipe(Effect.timeout("5 seconds"))
|
||||
const secondPlugin = yield* Deferred.await(secondReady).pipe(Effect.timeout("5 seconds"))
|
||||
const firstSession = yield* firstPlugin.session.create({ title: "first application" })
|
||||
const secondSession = yield* secondPlugin.session.create({ title: "second application" })
|
||||
|
||||
expect(Context.get(first, Database.Service)).not.toBe(Context.get(second, Database.Service))
|
||||
expect((yield* Context.get(first, Session.Service).get(firstSession.id)).title).toBe("first application")
|
||||
expect(Exit.isFailure(yield* Context.get(second, Session.Service).get(firstSession.id).pipe(Effect.exit))).toBe(
|
||||
true,
|
||||
)
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
expect((yield* secondPlugin.session.get({ sessionID: secondSession.id })).title).toBe("second application")
|
||||
expect((yield* secondPlugin.session.create({ title: "still running" })).title).toBe("still running")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves interruption options through the application-owned plugin bridge", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: { sessionID: Session.ID; options?: { readonly continue?: boolean } }[] = []
|
||||
const context = yield* Layer.build(
|
||||
Application.build(PluginRuntime.node, [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Database.node, Database.configured({ path: ":memory:" })],
|
||||
[
|
||||
SessionExecution.node,
|
||||
Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.sync(() => {
|
||||
seen.push({ sessionID, options })
|
||||
return true
|
||||
}),
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
const runtime = Context.get(context, PluginRuntime.Service)
|
||||
const sessionID = Session.ID.create()
|
||||
expect(yield* runtime.session.interrupt(sessionID, { continue: true })).toBe(true)
|
||||
expect(seen).toEqual([{ sessionID, options: { continue: true } }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -58,6 +58,23 @@ void checkError
|
||||
|
||||
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.mergeAll(aLayer, Layer.succeed(B, B.of({}))), deps: [] })]])
|
||||
|
||||
const invalidMissingOutputs = () => {
|
||||
const empty = make({ service: A, layer: Layer.empty, deps: [] })
|
||||
const bundle = make({ name: "bundle", layer: Layer.mergeAll(aLayer, Layer.succeed(B, B.of({}))), deps: [] })
|
||||
const partial = make({ name: "bundle", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error A node replacement cannot remove all source outputs
|
||||
LayerNode.compile(a, [[a, empty]])
|
||||
// @ts-expect-error A node replacement must preserve every bundled output
|
||||
LayerNode.compile(bundle, [[bundle, partial]])
|
||||
// @ts-expect-error Hoisting enforces the same replacement output coverage
|
||||
LayerNode.hoist(bundle, tags.values.app, [[bundle, partial]])
|
||||
// @ts-expect-error Effective-graph inspection validates replacements too
|
||||
LayerNode.hasUnbound(inputDependent, inputA, [[a, empty]])
|
||||
}
|
||||
void invalidMissingOutputs
|
||||
|
||||
// @ts-expect-error Replacement must provide A
|
||||
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
|
||||
|
||||
@@ -43,6 +43,58 @@ describe("layer node", () => {
|
||||
void check
|
||||
})
|
||||
|
||||
test("keeps transitive dependencies private at runtime", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Greeting).value, (yield* Effect.serviceOption(Value))._tag]
|
||||
}).pipe(Effect.provide(LayerNode.compile(greeting)))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["hello production", "None"])
|
||||
})
|
||||
|
||||
test("builds roots in order and supplies earlier roots to later roots", async () => {
|
||||
const acquired: string[] = []
|
||||
const first = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquired.push("first")
|
||||
return Value.of({ value: "first" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const second = make({
|
||||
service: Greeting,
|
||||
layer: Layer.effect(
|
||||
Greeting,
|
||||
Effect.map(Effect.serviceOption(Value), (value) => {
|
||||
acquired.push("second")
|
||||
return Greeting.of({ value: value._tag })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([first, second]))),
|
||||
),
|
||||
),
|
||||
).toBe("Some")
|
||||
expect(acquired).toEqual(["first", "second"])
|
||||
acquired.length = 0
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([second, first]))),
|
||||
),
|
||||
),
|
||||
).toBe("None")
|
||||
expect(acquired).toEqual(["second", "first"])
|
||||
})
|
||||
|
||||
test("preserves branch-specific implementations across roots", async () => {
|
||||
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
|
||||
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
||||
@@ -158,6 +210,128 @@ describe("layer node", () => {
|
||||
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
|
||||
})
|
||||
|
||||
test("applies earlier replacements inside later replacement nodes", async () => {
|
||||
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, [
|
||||
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
|
||||
[greeting, replacement],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
|
||||
})
|
||||
|
||||
test("rejects replacements matching an actual target in another tag", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")({ service: Value, layer: valueLayer, deps: [] })
|
||||
const location = tags.make("location")({ service: Value, layer: valueLayer, deps: [] })
|
||||
const unbound = LayerNode.unbound(Greeting, tags.values.location)
|
||||
const replacements = [[global, valueLayer]] as const
|
||||
|
||||
expect(() => LayerNode.compile(location, replacements)).toThrow("Cannot replace test/LayerNodeValue across tags")
|
||||
expect(() => LayerNode.hoist(location, tags.values.global, replacements)).toThrow(
|
||||
"Cannot replace test/LayerNodeValue across tags",
|
||||
)
|
||||
expect(() => LayerNode.hasUnbound(location, unbound, replacements)).toThrow(
|
||||
"Cannot replace test/LayerNodeValue across tags",
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves same-tag replacements by service name", async () => {
|
||||
const variant = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "variant" })), deps: [] })
|
||||
const program = Effect.map(Value, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.compile(variant, [[value, Layer.succeed(Value, Value.of({ value: "replacement" }))]])),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("replacement")
|
||||
})
|
||||
|
||||
test("matches equivalent same-name source definitions", async () => {
|
||||
const source = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, [[source, Layer.succeed(Greeting, Greeting.of({ value: "replacement" }))]]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("replacement")
|
||||
})
|
||||
|
||||
test("checks cycles after the final replacement wins", async () => {
|
||||
const replacementValue = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
|
||||
),
|
||||
deps: [greeting],
|
||||
})
|
||||
const replacementGreeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const cycle = [
|
||||
[value, replacementValue],
|
||||
[greeting, replacementGreeting],
|
||||
] as const
|
||||
const replacements = [...cycle, [value, value]] as const
|
||||
|
||||
expect(() => LayerNode.compile(greeting, cycle)).toThrow("Cycle detected in layer tree")
|
||||
expect(() => LayerNode.hoist(greeting, tags.values.app, cycle)).toThrow("Cycle detected in layer tree")
|
||||
const split = LayerNode.hoist(greeting, tags.values.app, replacements)
|
||||
const read = Effect.map(Greeting, (item) => item.value)
|
||||
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(greeting, replacements))))).toBe(
|
||||
"hello production",
|
||||
)
|
||||
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(split.hoisted))))).toBe(
|
||||
"hello production",
|
||||
)
|
||||
})
|
||||
|
||||
test("applies final overrides to dependencies referencing earlier replacements", async () => {
|
||||
const profileValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "profile" })), deps: [] })
|
||||
const profileGreeting = make({ service: Greeting, layer: greetingLayer, deps: [profileValue] })
|
||||
const overrideValue = make({
|
||||
service: Value,
|
||||
layer: Layer.succeed(Value, Value.of({ value: "override" })),
|
||||
deps: [],
|
||||
})
|
||||
const replacements = [
|
||||
[value, profileValue],
|
||||
[greeting, profileGreeting],
|
||||
[value, overrideValue],
|
||||
] as const
|
||||
const read = Effect.map(Greeting, (item) => item.value)
|
||||
const split = LayerNode.hoist(greeting, tags.values.app, replacements)
|
||||
|
||||
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(greeting, replacements))))).toBe(
|
||||
"hello override",
|
||||
)
|
||||
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(split.hoisted))))).toBe("hello override")
|
||||
})
|
||||
|
||||
test("inspects unbound nodes in the effective replacement graph", () => {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const independent = make({
|
||||
service: Greeting,
|
||||
layer: Layer.succeed(Greeting, Greeting.of({ value: "plain" })),
|
||||
deps: [],
|
||||
})
|
||||
const dependent = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
|
||||
expect(LayerNode.hasUnbound(independent, unbound)).toBe(false)
|
||||
expect(LayerNode.hasUnbound(independent, unbound, [[independent, dependent]])).toBe(true)
|
||||
expect(LayerNode.hasUnbound(dependent, unbound, [[dependent, independent]])).toBe(false)
|
||||
expect(LayerNode.hasUnbound(dependent, unbound, [[unbound, value]])).toBe(false)
|
||||
expect(
|
||||
LayerNode.hasUnbound(independent, unbound, [
|
||||
[independent, dependent],
|
||||
[unbound, value],
|
||||
]),
|
||||
).toBe(false)
|
||||
expect(() => LayerNode.hasUnbound(independent, value)).toThrow("Cannot check non-unbound layer node")
|
||||
})
|
||||
|
||||
test("hoists and compiles tagged graphs", async () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
@@ -237,6 +411,100 @@ describe("layer node", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects conflicting implementations below another hoisted node", () => {
|
||||
const competing = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "other" })), deps: [] })
|
||||
|
||||
expect(() => LayerNode.hoist(LayerNode.group([greeting, competing]), tags.values.app)).toThrow(
|
||||
"Tag app has conflicting implementations for test/LayerNodeValue",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects hoisted nodes with the same implementation but different dependencies", () => {
|
||||
const firstValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const secondValue = LayerNode.make({
|
||||
service: Value,
|
||||
layer: Layer.succeed(Value, Value.of({ value: "other" })),
|
||||
deps: [],
|
||||
})
|
||||
const first = make({ service: Greeting, layer: greetingLayer, deps: [firstValue] })
|
||||
const second = make({ service: Greeting, layer: greetingLayer, deps: [secondValue] })
|
||||
|
||||
expect(() => LayerNode.hoist(LayerNode.group([first, second]), tags.values.app)).toThrow(
|
||||
"Tag app has conflicting implementations for test/LayerNodeGreeting",
|
||||
)
|
||||
})
|
||||
|
||||
test("deduplicates matching hoisted definitions after applying replacements", async () => {
|
||||
const duplicate = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const split = LayerNode.hoist(LayerNode.group([greeting, duplicate]), tags.values.app, [
|
||||
[value, Layer.succeed(Value, Value.of({ value: "replacement" }))],
|
||||
])
|
||||
|
||||
expect(split.hoisted.dependencies).toHaveLength(1)
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.compile(split.hoisted))),
|
||||
),
|
||||
).toBe("hello replacement")
|
||||
})
|
||||
|
||||
test("deduplicates equivalent global closures through transparent dependency groups", () => {
|
||||
const duplicateValue = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const duplicateGreeting = make({
|
||||
service: Greeting,
|
||||
layer: greetingLayer,
|
||||
deps: [LayerNode.group([duplicateValue])],
|
||||
})
|
||||
const split = LayerNode.hoist(LayerNode.group([greeting, duplicateGreeting]), tags.values.app)
|
||||
|
||||
expect(split.hoisted.dependencies).toEqual([greeting])
|
||||
})
|
||||
|
||||
test("accepts equivalent untagged dependency closures while hoisting", async () => {
|
||||
const firstValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const secondValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const first = make({ service: Greeting, layer: greetingLayer, deps: [firstValue] })
|
||||
const second = make({ service: Greeting, layer: greetingLayer, deps: [secondValue] })
|
||||
const split = LayerNode.hoist(LayerNode.group([first, second]), tags.values.app)
|
||||
|
||||
expect(split.hoisted.dependencies).toHaveLength(1)
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.compile(split.hoisted))),
|
||||
),
|
||||
).toBe("hello production")
|
||||
})
|
||||
|
||||
test("keeps hoisted services shared outside fresh local builds", async () => {
|
||||
const acquisitions = { global: 0, local: 0 }
|
||||
const shared = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => Value.of({ value: String(++acquisitions.global) })),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const local = LayerNode.make({
|
||||
service: Greeting,
|
||||
layer: Layer.effect(
|
||||
Greeting,
|
||||
Effect.map(Value, (item) => Greeting.of({ value: `${item.value}:${++acquisitions.local}` })),
|
||||
),
|
||||
deps: [shared],
|
||||
})
|
||||
const split = LayerNode.hoist(local, tags.values.app)
|
||||
const read = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(Layer.fresh(LayerNode.compile(split.node))),
|
||||
)
|
||||
const program = Effect.gen(function* () {
|
||||
return [yield* read, yield* read]
|
||||
}).pipe(Effect.provide(LayerNode.compile(split.hoisted)))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["1:1", "1:2"])
|
||||
expect(acquisitions).toEqual({ global: 1, local: 2 })
|
||||
})
|
||||
|
||||
test("treats dependency groups as transparent while hoisting", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
|
||||
@@ -31,6 +31,26 @@ describe("node build", () => {
|
||||
expect(await Effect.runPromise(program)).toBe("plain")
|
||||
})
|
||||
|
||||
test("binds a location service map introduced by a replacement", async () => {
|
||||
const original = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.succeed(Result, Result.of({ value: "original" })),
|
||||
deps: [],
|
||||
})
|
||||
const replacement = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "bound" }))),
|
||||
deps: [LocationServiceMap.node],
|
||||
})
|
||||
const value = await Effect.runPromise(
|
||||
Effect.map(Result, (result) => result.value).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(original, [[original, replacement]])),
|
||||
),
|
||||
)
|
||||
|
||||
expect(value).toBe("bound")
|
||||
})
|
||||
|
||||
test("detects cycles through a replaced location service map", async () => {
|
||||
const a = Node.makeGlobalNode({
|
||||
service: CycleA,
|
||||
|
||||
@@ -131,6 +131,37 @@ describe("Git worktrees", () => {
|
||||
})
|
||||
|
||||
describe("Git trees", () => {
|
||||
it.live("lists both sides of a rename as separate file changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(root.path)
|
||||
await $`git config diff.renames true`.cwd(root.path).quiet()
|
||||
await Bun.write(path.join(root.path, "old name.txt"), "Preserve this content.\n")
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
yield* Effect.promise(() => fs.rename(path.join(root.path, "old name.txt"), path.join(root.path, "new name.txt")))
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
|
||||
RelativePath.make("new name.txt"),
|
||||
RelativePath.make("old name.txt"),
|
||||
])
|
||||
expect(
|
||||
(yield* git.tree.diff({ repository, from: before, to: after })).map((file) => [file.file, file.status]),
|
||||
).toEqual([
|
||||
["new name.txt", "added"],
|
||||
["old name.txt", "deleted"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -64,8 +64,10 @@ export const registerToolPlugin = <R>(
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
transform: (callback) =>
|
||||
tools
|
||||
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
|
||||
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1032,6 +1032,58 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
draft.set("dynamic", {
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
})
|
||||
})
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
|
||||
const settings = { disabled: true }
|
||||
yield* service.transform((draft) => {
|
||||
draft.update("dynamic", (server) => {
|
||||
server.disabled = settings.disabled
|
||||
})
|
||||
})
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe("disabled")
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
|
||||
settings.disabled = false
|
||||
yield* service.reload()
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
|
||||
const removed = yield* service.transform((draft) => draft.remove("dynamic"))
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
|
||||
yield* removed.dispose
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1229,7 +1281,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mutations on catalog updates", () =>
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = (server: string, name: string) =>
|
||||
new MCP.Tool({
|
||||
@@ -1246,14 +1298,12 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mu
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const policy = yield* registry.transform((draft) => {
|
||||
draft.update("demo_search", (tool) => {
|
||||
tool.description = "Updated search"
|
||||
})
|
||||
draft.remove("other_lookup")
|
||||
})
|
||||
yield* registration.flush
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_search", "execute"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_search",
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
@@ -1261,36 +1311,23 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mu
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_search",
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
||||
"Updated search",
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_demo_search", name: "demo_search", input: {} },
|
||||
}),
|
||||
).toMatchObject({ status: "completed" })
|
||||
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
|
||||
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
|
||||
)
|
||||
|
||||
yield* Ref.set(catalog, [
|
||||
tool("demo", "status"),
|
||||
tool("other", "lookup"),
|
||||
tool("demo", "added"),
|
||||
tool("repaired", "lookup"),
|
||||
])
|
||||
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_status")
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_status",
|
||||
"repaired_lookup",
|
||||
"execute",
|
||||
])
|
||||
yield* policy.dispose
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_search",
|
||||
"demo_status",
|
||||
"other_lookup",
|
||||
"repaired_lookup",
|
||||
@@ -1324,7 +1361,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mu
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("advertises MCP output schemas to Code Mode", () =>
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
@@ -1341,7 +1378,7 @@ it.live("advertises MCP output schemas to Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns content-only MCP results through Code Mode", () =>
|
||||
it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
@@ -1366,7 +1403,7 @@ it.live("returns content-only MCP results through Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
yield* waitForTool(registry, "direct_lookup")
|
||||
@@ -1380,7 +1417,7 @@ it.live("advertises MCP tools directly when Code Mode is disabled for the server
|
||||
|
||||
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
|
||||
// success whose text happens to describe an error.
|
||||
it.live("fails the call when MCP reports isError", () =>
|
||||
it.effect("fails the call when MCP reports isError", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
@@ -1398,7 +1435,7 @@ it.live("fails the call when MCP reports isError", () =>
|
||||
)
|
||||
|
||||
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
|
||||
it.live("preserves MCP text and media content for the model", () =>
|
||||
it.effect("preserves MCP text and media content for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
@@ -1419,7 +1456,7 @@ it.live("preserves MCP text and media content for the model", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("waits for permission before calling an MCP tool", () =>
|
||||
it.effect("waits for permission before calling an MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
@@ -1461,7 +1498,7 @@ it.live("waits for permission before calling an MCP tool", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not call MCP when permission is blocked", () =>
|
||||
it.effect("does not call MCP when permission is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
|
||||
@@ -25,6 +25,34 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("Plugin", () => {
|
||||
it.effect("exposes the current location to activated plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const location = yield* Location.Service
|
||||
const seen: Location.Info[] = []
|
||||
yield* plugins.activate([
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id: "location-context",
|
||||
effect: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(ctx.location)
|
||||
}),
|
||||
}),
|
||||
"1",
|
||||
),
|
||||
])
|
||||
|
||||
expect(seen).toEqual([
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -43,7 +71,7 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes explicit MCP locations through the plugin runtime", () =>
|
||||
it.effect("exposes MCP reads and transforms and routes explicit read locations", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
@@ -72,10 +100,6 @@ describe("Plugin", () => {
|
||||
data: [],
|
||||
}
|
||||
}),
|
||||
add: (ref) => Effect.sync(() => routed.push(`add:${ref.directory}`)),
|
||||
remove: (ref) => Effect.sync(() => routed.push(`remove:${ref.directory}`)),
|
||||
connect: (ref) => Effect.sync(() => routed.push(`connect:${ref.directory}`)),
|
||||
disconnect: (ref) => Effect.sync(() => routed.push(`disconnect:${ref.directory}`)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -83,14 +107,9 @@ describe("Plugin", () => {
|
||||
)
|
||||
const location = { directory: target }
|
||||
|
||||
yield* host.mcp
|
||||
.add({ location, server: "routed", config: { type: "local", command: ["unused"], disabled: true } })
|
||||
.pipe(Effect.orDie)
|
||||
yield* host.mcp.remove({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
expect(Object.keys(host.mcp).sort()).toEqual(["list", "reload", "transform"])
|
||||
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
||||
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
|
||||
expect(routed).toEqual(["list:/target"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
|
||||
export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
return {
|
||||
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
|
||||
location:
|
||||
overrides.location ??
|
||||
new Location.Info({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
project: {
|
||||
id: Project.ID.global,
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
canonical: AbsolutePath.make("/workspace"),
|
||||
},
|
||||
}),
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
@@ -77,10 +87,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
mcp: overrides.mcp ?? {
|
||||
list: () => Effect.die("unused mcp.list"),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
remove: () => Effect.die("unused mcp.remove"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
@@ -114,7 +120,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
|
||||
@@ -72,7 +72,6 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
},
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.after") {
|
||||
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -28,6 +29,41 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("exposes the host location including workspace and project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const location = yield* Location.Service
|
||||
const expected = new Location.Info({
|
||||
directory: AbsolutePath.make("/worktree/packages/app"),
|
||||
workspaceID: Workspace.ID.make("wrk_plugin_location"),
|
||||
project: {
|
||||
id: Project.ID.global,
|
||||
directory: AbsolutePath.make("/worktree"),
|
||||
canonical: AbsolutePath.make("/project"),
|
||||
},
|
||||
})
|
||||
const host = yield* PluginHost.make(plugins).pipe(
|
||||
Effect.provideService(Location.Service, {
|
||||
...location,
|
||||
directory: expected.directory,
|
||||
workspaceID: expected.workspaceID,
|
||||
project: expected.project,
|
||||
}),
|
||||
)
|
||||
const seen: Location.Info[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-location",
|
||||
setup: (ctx) => {
|
||||
seen.push(ctx.location)
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(seen).toEqual([expected])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts plugin storage methods", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -242,12 +278,14 @@ describe("fromPromise", () => {
|
||||
const promisePlugin = define({
|
||||
id: "promise-client-reads",
|
||||
setup: async (ctx) => {
|
||||
expect(Object.keys(ctx.mcp).sort()).toEqual(["list", "reload", "transform"])
|
||||
const results = await Promise.all([
|
||||
ctx.agent.list(),
|
||||
ctx.catalog.provider.list(),
|
||||
ctx.catalog.model.list(),
|
||||
ctx.command.list(),
|
||||
ctx.integration.list(),
|
||||
ctx.mcp.list(),
|
||||
ctx.plugin.list(),
|
||||
ctx.reference.list(),
|
||||
ctx.skill.list(),
|
||||
@@ -259,7 +297,7 @@ describe("fromPromise", () => {
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
|
||||
expect(seen).toHaveLength(8)
|
||||
expect(seen).toHaveLength(9)
|
||||
expect(new Set(seen).size).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -636,77 +674,4 @@ describe("fromPromise", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adapts tool mutation, replay, and disposal through the Promise API", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const progress: Tool.Metadata[] = []
|
||||
let greeting = "Hello"
|
||||
let registration: { dispose(): Promise<void> } | undefined
|
||||
yield* host.tool.transform((draft) => {
|
||||
const text = greeting
|
||||
draft.add({
|
||||
name: "hello",
|
||||
description: "Hello",
|
||||
options: { namespace: "acme", codemode: false },
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ name }, context) =>
|
||||
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
|
||||
})
|
||||
draft.add({
|
||||
name: "temporary",
|
||||
description: "Temporary",
|
||||
input: Schema.Struct({}),
|
||||
options: { codemode: false },
|
||||
execute: () => Effect.succeed({ content: "temporary" }),
|
||||
})
|
||||
})
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-update",
|
||||
setup: async (ctx) => {
|
||||
registration = await ctx.tool.transform((draft) => {
|
||||
draft.update("missing", () => {
|
||||
throw new Error("must not create a tool")
|
||||
})
|
||||
draft.update("acme_hello", (tool) => {
|
||||
const execute = tool.execute
|
||||
tool.description = "Wrapped"
|
||||
tool.execute = async (input, context) => {
|
||||
const result = await execute(input, context)
|
||||
return { ...result, output: `${result.output} Wrapped.` }
|
||||
}
|
||||
})
|
||||
draft.remove("temporary")
|
||||
})
|
||||
greeting = "Hi"
|
||||
await ctx.tool.reload()
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const snapshot = yield* registry.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
|
||||
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
|
||||
expect(
|
||||
yield* snapshot.execute({
|
||||
sessionID: Session.ID.make("ses_promise_update"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_update"),
|
||||
progress: (value) => Effect.sync(() => progress.push(value)),
|
||||
call: { type: "tool-call", id: "call_promise_update", name: "acme_hello", input: { name: "world" } },
|
||||
}),
|
||||
).toMatchObject({ output: "Hi, world! Wrapped." })
|
||||
expect(progress).toEqual([{ phase: "original" }])
|
||||
const registered = registration
|
||||
if (!registered) throw new Error("Expected registration")
|
||||
yield* Effect.promise(() => registered.dispose())
|
||||
yield* Effect.promise(() => registered.dispose())
|
||||
const restored = yield* registry.snapshot()
|
||||
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
|
||||
expect(restored.definitions[0]?.description).toBe("Hello")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Session.revert files", () => {
|
||||
it.live("undoes and restores a file rename without losing either path", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const original = path.join(directory, "old name.txt")
|
||||
const renamed = path.join(directory, "new name.txt")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await Bun.write(original, "Preserve this content.\n")
|
||||
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
|
||||
const session = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(() => fs.rename(original, renamed))
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Renamed snapshot missing")
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
snapshot: after,
|
||||
files: yield* snapshot.files({ from: before, to: after }),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
|
||||
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
|
||||
expect({
|
||||
original: yield* Effect.promise(() => Bun.file(original).exists()),
|
||||
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
|
||||
}).toEqual({ original: true, renamed: false })
|
||||
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
|
||||
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
|
||||
["new name.txt", "deleted"],
|
||||
["old name.txt", "added"],
|
||||
])
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -984,7 +984,7 @@ Recent work
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Visible thought" },
|
||||
{ type: "reasoning", text: "Visible thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-old-model",
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||
@@ -72,138 +71,6 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
|
||||
)
|
||||
|
||||
describe("Tool", () => {
|
||||
it.live("replays updates and removals on reload and restores definitions on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let text = "original"
|
||||
const source = yield* service.transform((draft) =>
|
||||
draft.add({
|
||||
...constant(text),
|
||||
name: "echo",
|
||||
description: text,
|
||||
options: { namespace: "acme", codemode: false },
|
||||
}),
|
||||
)
|
||||
yield* transform(service, { echo: make() }, { namespace: "other", codemode: false })
|
||||
const before = yield* service.snapshot()
|
||||
const update = yield* service.transform((draft) => {
|
||||
draft.update("missing", () => {
|
||||
throw new Error("must not create a tool")
|
||||
})
|
||||
draft.update("acme_echo", (tool) => {
|
||||
tool.description += " updated"
|
||||
const execute = tool.execute
|
||||
tool.execute = (input, context) =>
|
||||
execute(input, context).pipe(
|
||||
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
|
||||
)
|
||||
})
|
||||
})
|
||||
const removal = yield* service.transform((draft) => {
|
||||
draft.remove("missing")
|
||||
draft.remove("other_echo")
|
||||
})
|
||||
const updated = yield* service.snapshot()
|
||||
expect(updated.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
|
||||
expect(updated.definitions[0]?.description).toBe("original updated")
|
||||
expect((yield* updated.execute(call("acme_echo"))).output).toEqual({ text: "original updated" })
|
||||
text = "refreshed"
|
||||
yield* service.reload()
|
||||
const reloaded = yield* service.snapshot()
|
||||
expect(reloaded.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
|
||||
expect(reloaded.definitions[0]?.description).toBe("refreshed updated")
|
||||
expect((yield* reloaded.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
|
||||
expect((yield* before.execute(call("acme_echo"))).output).toEqual({ text: "original" })
|
||||
yield* removal.dispose
|
||||
yield* removal.dispose
|
||||
yield* update.dispose
|
||||
const restored = yield* service.snapshot()
|
||||
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "other_echo", "execute"])
|
||||
expect((yield* restored.execute(call("acme_echo"))).output).toEqual({ text: "refreshed" })
|
||||
yield* source.dispose
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["other_echo", "execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not retain an updated tool after its source scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("echo", (tool) => {
|
||||
tool.description = "Updated"
|
||||
}),
|
||||
)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches tool transforms with the shared state lifecycle", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let runs = 0
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
runs++
|
||||
draft.add({ ...make(), options: { codemode: false } })
|
||||
})
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("echo", (tool) => {
|
||||
tool.description = "Batched"
|
||||
}),
|
||||
)
|
||||
expect(runs).toBe(0)
|
||||
}),
|
||||
)
|
||||
expect(runs).toBe(1)
|
||||
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Batched")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips invalid updates without dropping the existing definition", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("echo", (tool) => {
|
||||
Object.assign(tool, { description: undefined })
|
||||
}),
|
||||
)
|
||||
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates newly added tools and applies removals in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), options: { codemode: false } })
|
||||
draft.update("echo", (tool) => {
|
||||
tool.description = "Updated"
|
||||
tool.input = Schema.Struct({ value: Schema.Number })
|
||||
tool.output = Schema.Number
|
||||
tool.execute = ({ value }) => Effect.succeed({ output: value * 2 })
|
||||
})
|
||||
draft.add({ ...make(), name: "removed" })
|
||||
draft.remove("removed")
|
||||
draft.add({ ...make(), name: "removed" })
|
||||
draft.remove("removed")
|
||||
})
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect(snapshot.definitions[0]?.description).toBe("Updated")
|
||||
expect(
|
||||
(yield* snapshot.execute({
|
||||
...call("echo"),
|
||||
call: { type: "tool-call", id: "updated", name: "echo", input: { value: 3 } },
|
||||
})).output,
|
||||
).toBe(6)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs and skips invalid dotted namespaces", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Application } from "@opencode-ai/core/application"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -146,7 +146,6 @@ const nodes = LayerNode.group([
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
@@ -157,10 +156,10 @@ const replacements = [
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
||||
const productionIt = testEffect(Application.build(nodes, replacements))
|
||||
const it = testEffect(Application.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
||||
const permissionIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [
|
||||
Application.build(LayerNode.group([nodes, PermissionSaved.node]), [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[PluginSupervisor.node, shellPluginSupervisor],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Application } from "@opencode-ai/core/application"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -111,15 +111,14 @@ const nodes = LayerNode.group([
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
|
||||
const productionIt = testEffect(Application.build(nodes, replacements))
|
||||
const it = testEffect(Application.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
|
||||
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -17,7 +17,8 @@ const oc2Background = {
|
||||
}
|
||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||
const titlebarHeight = 40
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
const titlebarHeight = 44
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
let backgroundColor: string | undefined
|
||||
|
||||
@@ -6,6 +6,7 @@ export { Command } from "@opencode-ai/schema/command"
|
||||
export { Connection } from "@opencode-ai/schema/connection"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface MCPDraft {
|
||||
remove(name: string): void
|
||||
}
|
||||
|
||||
export interface MCPDomain extends Omit<McpApi<unknown>, "resource"> {
|
||||
export interface MCPDomain extends Pick<McpApi<unknown>, "list"> {
|
||||
readonly transform: Transform<MCPDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { VcsDiscovery } from "../vcs.js"
|
||||
@@ -22,6 +23,7 @@ import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
readonly location: Location.Info
|
||||
readonly options: PluginOptions
|
||||
readonly agent: AgentDomain
|
||||
readonly aisdk: AISDKDomain
|
||||
|
||||
@@ -2,16 +2,13 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Effect, JsonSchema, Types } from "effect"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolDraft {
|
||||
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
|
||||
tool: Tool.Info<Input, Output>,
|
||||
): void
|
||||
/** Updates an existing tool; missing IDs are ignored. */
|
||||
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
|
||||
remove(id: string): void
|
||||
}
|
||||
|
||||
export interface ToolHooks {
|
||||
@@ -51,6 +48,5 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
readonly hook: Hooks<ToolHooks, ToolFailures>
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
location: host.location,
|
||||
options: host.options,
|
||||
agent: {
|
||||
get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
|
||||
@@ -260,10 +261,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
mcp: {
|
||||
list: adaptApiMethod(McpEndpoints["mcp.list"], host.mcp.list),
|
||||
add: adaptApiMethod(McpEndpoints["mcp.add"], host.mcp.add),
|
||||
remove: adaptApiMethod(McpEndpoints["mcp.remove"], host.mcp.remove),
|
||||
connect: adaptApiMethod(McpEndpoints["mcp.connect"], host.mcp.connect),
|
||||
disconnect: adaptApiMethod(McpEndpoints["mcp.disconnect"], host.mcp.disconnect),
|
||||
transform: transform(host.mcp),
|
||||
reload: () => run(host.mcp.reload()),
|
||||
},
|
||||
@@ -294,31 +291,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
scan: (options) => run(host.storage.scan(options)),
|
||||
},
|
||||
tool: {
|
||||
reload: () => run(host.tool.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.tool.transform((draft) =>
|
||||
callback({
|
||||
update: (id, update) =>
|
||||
draft.update(id, (tool) => {
|
||||
const execute = tool.execute
|
||||
const value: Info = {
|
||||
...tool,
|
||||
execute: (input, context) =>
|
||||
run(
|
||||
execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.promise(() => context.progress(update)),
|
||||
}),
|
||||
),
|
||||
}
|
||||
update(value)
|
||||
Object.assign(tool, {
|
||||
...value,
|
||||
execute: (input: unknown, context: Tool.Context) => executePromiseTool(value, input, context),
|
||||
})
|
||||
}),
|
||||
remove: (id) => draft.remove(id),
|
||||
add: (tool: Info) =>
|
||||
draft.add({
|
||||
...tool,
|
||||
|
||||
@@ -7,6 +7,7 @@ export { Command } from "@opencode-ai/schema/command"
|
||||
export { Connection } from "@opencode-ai/schema/connection"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface MCPDraft {
|
||||
remove(name: string): void
|
||||
}
|
||||
|
||||
export interface MCPDomain extends Omit<McpApi, "resource"> {
|
||||
export interface MCPDomain extends Pick<McpApi, "list"> {
|
||||
readonly transform: Transform<MCPDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { VcsDiscovery } from "../vcs.js"
|
||||
import type { App } from "../app.js"
|
||||
@@ -21,6 +22,7 @@ import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
readonly location: Location.Info
|
||||
readonly options: PluginOptions
|
||||
readonly agent: AgentDomain
|
||||
readonly aisdk: AISDKDomain
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolContext extends Omit<Tool.Context, "progress"> {
|
||||
@@ -26,9 +26,6 @@ interface ToolDraft {
|
||||
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
|
||||
tool: Info<Input, Output>,
|
||||
): void
|
||||
/** Updates an existing tool; missing IDs are ignored. */
|
||||
update(id: string, update: (tool: Types.Mutable<Info>) => void): void
|
||||
remove(id: string): void
|
||||
}
|
||||
|
||||
interface ToolHooks {
|
||||
@@ -62,6 +59,5 @@ interface ToolHooks {
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
readonly hook: Hooks<ToolHooks>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Command } from "@opencode-ai/schema/command"
|
||||
import { Connection } from "@opencode-ai/schema/connection"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -26,6 +27,7 @@ test.each([
|
||||
expect(entrypoint.Connection).toBe(Connection)
|
||||
expect(entrypoint.Credential).toBe(Credential)
|
||||
expect(entrypoint.Integration).toBe(Integration)
|
||||
expect(entrypoint.Location).toBe(Location)
|
||||
expect(entrypoint.Mcp).toBe(Mcp)
|
||||
expect(entrypoint.Model).toBe(Model)
|
||||
expect(entrypoint.Provider).toBe(Provider)
|
||||
@@ -39,6 +41,7 @@ test.each([
|
||||
"Connection",
|
||||
"Credential",
|
||||
"Integration",
|
||||
"Location",
|
||||
"Mcp",
|
||||
"Model",
|
||||
"Plugin",
|
||||
|
||||
@@ -1,44 +1,11 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ApplicationOptions } from "@opencode-ai/core/application/options"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const ServerOptions = Schema.Struct({
|
||||
app: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
channel: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
...ApplicationOptions.Options.fields,
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
simulation: Schema.optional(Schema.Boolean),
|
||||
database: Schema.optional(Database.Options),
|
||||
events: Schema.optional(
|
||||
Schema.Struct({
|
||||
persist: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
models: Schema.optional(ModelsDev.Options),
|
||||
config: Schema.optional(
|
||||
Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
file: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
windows: Schema.optional(
|
||||
Schema.Struct({
|
||||
gitbash: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
fs: Schema.optional(
|
||||
Schema.Struct({
|
||||
filewatcher: Schema.optional(Schema.Boolean),
|
||||
fff: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ServerOptions = typeof ServerOptions.Type
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { V1Migration } from "@opencode-ai/core/database/v1-migration"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventLogger } from "@opencode-ai/core/event-logger"
|
||||
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Application } from "@opencode-ai/core/application"
|
||||
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
@@ -45,32 +21,6 @@ import { sessionLocationLayer } from "./middleware/session-location"
|
||||
import { ServerInfo } from "./server-info"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
const applicationServiceNodes = [
|
||||
Global.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
EventLogger.node,
|
||||
httpClient,
|
||||
Job.node,
|
||||
Project.node,
|
||||
Worktree.node,
|
||||
Session.node,
|
||||
SessionTransfer.node,
|
||||
PluginRuntime.providerNode,
|
||||
SdkPlugins.node,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
PersistentPty.node,
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
LocationServiceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
Workspace.node,
|
||||
] as const
|
||||
const applicationServices = LayerNode.group(applicationServiceNodes)
|
||||
|
||||
export function createRoutes(
|
||||
options: ServerOptions = {},
|
||||
serviceURLs: () => ReadonlyArray<string> = () => [],
|
||||
@@ -97,47 +47,15 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
|
||||
overrides: LayerNode.Replacements,
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const standard: LayerNode.Replacements = [
|
||||
[Database.node, Database.configured(options.database)],
|
||||
[Bus.node, Bus.configured({ persist: options.events?.persist })],
|
||||
[App.node, App.configured(options.app)],
|
||||
[ModelsDev.node, ModelsDev.configured(options.models)],
|
||||
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
|
||||
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
|
||||
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
|
||||
[
|
||||
Config.node,
|
||||
Config.configured({
|
||||
project: options.config?.project,
|
||||
file: options.config?.file,
|
||||
content: options.config?.content,
|
||||
}),
|
||||
],
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
|
||||
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
|
||||
[
|
||||
MCP.node,
|
||||
MCP.configured({
|
||||
clientInfo: {
|
||||
name: options.app?.name ?? "opencode",
|
||||
version: options.app?.version ?? "unknown",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
]
|
||||
const replacements: LayerNode.Replacements = [...standard, ...overrides]
|
||||
const serviceLayer = options.simulation
|
||||
? Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
|
||||
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
|
||||
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
|
||||
return Application.layer(options, [...overrides, ...simulation], PtyEnvironment.node)
|
||||
}),
|
||||
)
|
||||
: AppNodeBuilder.build(applicationServices, replacements)
|
||||
: Application.layer(options, overrides, PtyEnvironment.node)
|
||||
return serviceLayer.pipe(
|
||||
Layer.flatMap((context) => {
|
||||
const services = Layer.succeedContext(context)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { deflateSync } from "node:zlib"
|
||||
|
||||
const size = 16
|
||||
const frameRate = 90
|
||||
const frameRate = 30
|
||||
const duration = 1.2
|
||||
const frameCount = frameRate * duration
|
||||
const opacity = [0.2, 0.5, 0.75, 1]
|
||||
@@ -41,8 +41,8 @@ function apng(scale: number) {
|
||||
control.writeUInt32BE(sequence.value++, 0)
|
||||
control.writeUInt32BE(frameSize, 4)
|
||||
control.writeUInt32BE(frameSize, 8)
|
||||
control.writeUInt16BE(frame % 9 === 0 ? 12 : 11, 20)
|
||||
control.writeUInt16BE(1000, 22)
|
||||
control.writeUInt16BE(1, 20)
|
||||
control.writeUInt16BE(frameRate, 22)
|
||||
chunks.push(chunk("fcTL", control))
|
||||
|
||||
const data = deflateSync(pixels(scale, frame), { level: 9 })
|
||||
|
||||
@@ -1514,8 +1514,3 @@
|
||||
:root[data-color-scheme="light"] body [data-component="user-message"] [data-slot="user-message-text"] {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
:root body [data-workspace-session] [data-component="user-message"] [data-slot="user-message-text"] {
|
||||
background: var(--v2-background-bg-accent);
|
||||
color: var(--v2-text-text-contrast);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
test("progress indicator PNG loops 36 frames at 30 fps in a 16x16 canvas", async () => {
|
||||
const png = Buffer.from(
|
||||
await Bun.file(new URL("./session-progress-indicator-v2-1x.png", import.meta.url)).arrayBuffer(),
|
||||
)
|
||||
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
|
||||
const chunks: { type: string; data: Buffer }[] = []
|
||||
for (let offset = 8; offset < png.length; ) {
|
||||
const length = png.readUInt32BE(offset)
|
||||
expect(offset + length + 12).toBeLessThanOrEqual(png.length)
|
||||
chunks.push({
|
||||
type: png.toString("ascii", offset + 4, offset + 8),
|
||||
data: png.subarray(offset + 8, offset + 8 + length),
|
||||
})
|
||||
offset += length + 12
|
||||
}
|
||||
|
||||
expect(chunks[0].type).toBe("IHDR")
|
||||
expect(chunks[0].data.readUInt32BE(0)).toBe(16)
|
||||
expect(chunks[0].data.readUInt32BE(4)).toBe(16)
|
||||
expect(chunks.at(-1)?.type).toBe("IEND")
|
||||
|
||||
const animation = chunks.filter((chunk) => chunk.type === "acTL")
|
||||
expect(animation).toHaveLength(1)
|
||||
expect(animation[0].data.readUInt32BE(0)).toBe(36)
|
||||
expect(animation[0].data.readUInt32BE(4)).toBe(0)
|
||||
|
||||
const frames = chunks.filter((chunk) => chunk.type === "fcTL")
|
||||
expect(frames).toHaveLength(36)
|
||||
const delays = frames.map((frame) => frame.data.readUInt16BE(20) / (frame.data.readUInt16BE(22) || 100))
|
||||
expect(delays.every((delay) => delay === 1 / 30)).toBe(true)
|
||||
expect(delays.reduce((total, delay) => total + delay, 0)).toBeCloseTo(1.2, 10)
|
||||
})
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --timeout 30000",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -121,6 +121,12 @@ export const DEFAULT_THEME = {
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.800",
|
||||
question: "$text.feedback.info.default",
|
||||
permission: "$text.feedback.warning.default",
|
||||
unread: "$hue.accent.800",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
@@ -336,6 +342,12 @@ export const DEFAULT_THEME = {
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.feedback.info.default",
|
||||
permission: "$text.feedback.warning.default",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ThemeTokensDefinition } from "./index.js"
|
||||
import type { Mode, ThemeTokensDefinition } from "./index.js"
|
||||
import { DEFAULT_THEME } from "./defaults.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
export function fallback(mode: Mode): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
@@ -9,6 +10,7 @@ export function fallback(): ThemeTokensDefinition {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
status: DEFAULT_THEME[mode].text.status,
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const core = expandTokens(fallback(selected.mode))
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
|
||||
@@ -108,6 +108,14 @@ const TextDefinition = Schema.Struct({
|
||||
subdued: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
status: Schema.optional(
|
||||
Schema.Struct({
|
||||
running: Schema.optional(ColorValue),
|
||||
question: Schema.optional(ColorValue),
|
||||
permission: Schema.optional(ColorValue),
|
||||
unread: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
|
||||
@@ -30,6 +30,12 @@ export type ResolvedThemeTokens = {
|
||||
readonly subdued: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly status: {
|
||||
readonly running: RGBA
|
||||
readonly question: RGBA
|
||||
readonly permission: RGBA
|
||||
readonly unread: RGBA
|
||||
}
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
|
||||
}
|
||||
readonly background: {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument } from "../src/tui/index.js"
|
||||
import type { ThemeV1Json } from "../src/tui/v1.js"
|
||||
|
||||
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
|
||||
const source: ThemeV1Json = await Bun.file(
|
||||
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
|
||||
).json()
|
||||
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
|
||||
const theme = resolveThemeDocument(document, mode)
|
||||
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.feedback.info.default)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.feedback.warning.default)).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
feedback: { warning: { default: "#654321" } },
|
||||
status: { question: "#123456" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.running.equals(theme.hue.purple[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.orange[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
|
||||
}
|
||||
})
|
||||
@@ -144,6 +144,15 @@ export const settings: Setting[] = [
|
||||
values: ["horizontal", "vertical"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
title: "Indicators",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "indicators"],
|
||||
default: "status",
|
||||
values: ["status", "numbers"],
|
||||
labels: ["status icons", "always show numbers"],
|
||||
keywords: ["tab numbers", "number mode", "status icons"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
@@ -18,6 +18,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
@@ -66,10 +67,15 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
}
|
||||
const focusSession = () => {
|
||||
// Permission prompts replace the input, so returning focus must not depend on it.
|
||||
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
prompt.current?.focus()
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || selectedTerminal()) return
|
||||
setRestoreTerminalFocus(false)
|
||||
prompt.current?.focus()
|
||||
focusSession()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
@@ -77,9 +83,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
title: "Focus session pane",
|
||||
run: () => {
|
||||
prompt.current?.focus()
|
||||
},
|
||||
run: focusSession,
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
@@ -119,7 +123,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={1}
|
||||
onMouseDown={() => prompt.current?.focus()}
|
||||
// Consume the release before revealing permission buttons underneath.
|
||||
onMouseUp={focusSession}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Portal, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useData } from "../context/data"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
moveSessionTab,
|
||||
@@ -39,6 +39,26 @@ import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/m
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
import { SPINNER_FRAMES } from "./spinner-frames"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export const TAB_SPINNERS = {
|
||||
dots: { frames: SPINNER_FRAMES, interval: 80 },
|
||||
arcs: { frames: ["◜", "◝", "◞", "◟"], interval: 120 },
|
||||
quadrants: { frames: ["◴", "◷", "◶", "◵"], interval: 120 },
|
||||
line: { frames: ["|", "/", "-", "\\"], interval: 120 },
|
||||
}
|
||||
export type TabSpinner = keyof typeof TAB_SPINNERS
|
||||
|
||||
export const TAB_UNREAD_MARKERS = {
|
||||
"small-dot": "•",
|
||||
dot: "●",
|
||||
square: "▪",
|
||||
"large-square": "■",
|
||||
}
|
||||
export type TabUnreadMarker = keyof typeof TAB_UNREAD_MARKERS
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -89,6 +109,75 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
|
||||
|
||||
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
|
||||
if (status.attention) return theme.text.status[status.attention]
|
||||
if (status.unread === "error") return theme.text.feedback.error.default
|
||||
return undefined
|
||||
}
|
||||
|
||||
function TabIndicator(props: {
|
||||
status: SessionTabsStatus
|
||||
label: string
|
||||
width: number
|
||||
color: RGBA
|
||||
unreadColor: RGBA
|
||||
backgroundColor: RGBA
|
||||
flashColor: RGBA
|
||||
animations: boolean
|
||||
numbers: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
attributes?: number
|
||||
}) {
|
||||
const runs = () => props.status.busy && !props.status.attention
|
||||
const pending = createMemo(() => props.status.busy || Boolean(props.status.attention))
|
||||
const unread = createMemo(() => Boolean(props.status.unread) && !pending())
|
||||
const unreadColor = createMemo<RGBA>((previous) => (unread() ? props.unreadColor : previous), props.unreadColor)
|
||||
const fade = createAnimatable(
|
||||
{ opacity: unread() ? 1 : 0 },
|
||||
{ enabled: () => props.animations, transition: tween({ duration: 0.18 }) },
|
||||
)
|
||||
createComputed(() => {
|
||||
if (unread()) return fade.jump({ opacity: 1 })
|
||||
if (pending()) return fade.jump({ opacity: 0 })
|
||||
fade.animate({ opacity: 0 })
|
||||
})
|
||||
const fading = () => !props.status.unread && fade.value().opacity > 0
|
||||
const color = () => {
|
||||
if (props.numbers) return props.color
|
||||
if (unread()) return props.unreadColor
|
||||
if (!fading()) return props.color
|
||||
const opacity = fade.value().opacity
|
||||
// Brighten during the first fifth, then dissolve into the tab background.
|
||||
const flash = Math.max(0, 1 - Math.abs(opacity - 0.8) / 0.2)
|
||||
return tint(props.backgroundColor, tint(unreadColor(), props.flashColor, flash * 0.3), Math.min(1, opacity / 0.8))
|
||||
}
|
||||
const spinner = () => TAB_SPINNERS[props.spinner ?? "dots"]
|
||||
const label = () => {
|
||||
if (props.numbers) return props.label
|
||||
if (props.status.attention === "permission") return "!"
|
||||
if (props.status.attention === "question") return "?"
|
||||
if (runs()) return spinner().frames[0]
|
||||
if (props.label === "+") return "+"
|
||||
if (props.status.unread || fading()) return TAB_UNREAD_MARKERS[props.unreadMarker ?? "small-dot"]
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
<box width={props.width + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end" paddingRight={1}>
|
||||
<Show
|
||||
when={runs() && props.animations && !props.numbers}
|
||||
fallback={
|
||||
<text fg={color()} selectable={false} attributes={props.attributes}>
|
||||
{label()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<spinner frames={spinner().frames} interval={spinner().interval} color={props.color} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function createPreviewDoubleClick(tabs: SessionTabsController) {
|
||||
let previous: { sessionID: string; time: number } | undefined
|
||||
return (sessionID: string) => {
|
||||
@@ -106,6 +195,15 @@ function createPreviewDoubleClick(tabs: SessionTabsController) {
|
||||
}
|
||||
}
|
||||
|
||||
function createGlowLevel(dimmed: () => boolean, animations: () => boolean) {
|
||||
const motion = createAnimatable(
|
||||
{ level: dimmed() ? 0.7 : 1 },
|
||||
{ enabled: animations, transition: tween({ duration: 0.2 }) },
|
||||
)
|
||||
createEffect(() => motion.animate({ level: dimmed() ? 0.7 : 1 }))
|
||||
return () => motion.value().level
|
||||
}
|
||||
|
||||
function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) {
|
||||
const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) })
|
||||
let wasRunning = runs()
|
||||
@@ -360,35 +458,57 @@ export function SessionTabs(
|
||||
props: {
|
||||
controller?: SessionTabsController
|
||||
animations?: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
orientation?: "horizontal" | "vertical"
|
||||
width?: number
|
||||
} = {},
|
||||
) {
|
||||
const config = useConfig().data
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.orientation === "vertical"}>
|
||||
<VerticalSessionTabs controller={props.controller} animations={props.animations} width={props.width} />
|
||||
<VerticalSessionTabs
|
||||
controller={props.controller}
|
||||
animations={props.animations}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
numbers={config.tabs.indicators === "numbers"}
|
||||
width={props.width}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<HorizontalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
<HorizontalSessionTabs
|
||||
controller={props.controller}
|
||||
animations={props.animations}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
numbers={config.tabs.indicators === "numbers"}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) {
|
||||
function VerticalSessionTabs(props: {
|
||||
controller?: SessionTabsController
|
||||
animations?: boolean
|
||||
numbers: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
width?: number
|
||||
}) {
|
||||
const contextTabs = useSessionTabs()
|
||||
const tabs: SessionTabsController = props.controller ?? contextTabs
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
@@ -422,8 +542,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
runs: status.busy && !status.attention,
|
||||
glows:
|
||||
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
|
||||
glows: Boolean(
|
||||
status.attention || (tab.sessionID !== activeID() && !status.busy && status.unread !== undefined),
|
||||
),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
@@ -564,7 +685,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected()))
|
||||
const color = tint(base, glowHue(), numberGlow.value().level)
|
||||
const color = tabFeedbackColor(status(), theme) ?? tint(base, glowHue(), numberGlow.value().level)
|
||||
const runningColor = runs() ? activeNumber() : color
|
||||
return sweepLevel() === 0
|
||||
? tint(runningColor, theme.text.default, numberIgnition.value().level)
|
||||
@@ -575,20 +696,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
return selected() ? theme.text.default : theme.text.subdued
|
||||
}
|
||||
const complete = () => status().complete
|
||||
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent.
|
||||
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to the unread color.
|
||||
let lastGlowHue: RGBA | undefined
|
||||
const glowHue = () => {
|
||||
if (status().attention) return (lastGlowHue = theme.text.feedback.warning.default)
|
||||
if (status().unread === "error") return (lastGlowHue = theme.text.feedback.error.default)
|
||||
if (status().unread !== undefined) return (lastGlowHue = accent())
|
||||
return lastGlowHue ?? accent()
|
||||
const feedback = tabFeedbackColor(status(), theme)
|
||||
if (feedback) return (lastGlowHue = feedback)
|
||||
if (status().unread !== undefined) return (lastGlowHue = unreadColor())
|
||||
return lastGlowHue ?? unreadColor()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7))
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
|
||||
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45 * glowLevel()))
|
||||
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
|
||||
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25 * glowLevel()))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const detailTextColor = (index: number) =>
|
||||
detailFades()
|
||||
@@ -619,17 +741,24 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
})
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const previousGlowLevel = createGlowLevel(
|
||||
() => previous()?.sessionID === activeID() && Boolean(previousStatus().attention),
|
||||
animations,
|
||||
)
|
||||
const indicatorWidth = 10
|
||||
let lastPreviousGlowHue: RGBA | undefined
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return (lastPreviousGlowHue = theme.text.feedback.warning.default)
|
||||
if (previousStatus().unread === "error")
|
||||
return (lastPreviousGlowHue = theme.text.feedback.error.default)
|
||||
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent())
|
||||
return lastPreviousGlowHue ?? accent()
|
||||
const feedback = tabFeedbackColor(previousStatus(), theme)
|
||||
if (feedback) return (lastPreviousGlowHue = feedback)
|
||||
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = unreadColor())
|
||||
return lastPreviousGlowHue ?? unreadColor()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const separatorUpperColor = createMemo(() =>
|
||||
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
|
||||
)
|
||||
const separatorLowerColor = createMemo(() =>
|
||||
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
|
||||
)
|
||||
const titleColor = (index: number, separator: boolean) => {
|
||||
const level = titleGlow.value().level
|
||||
const color =
|
||||
@@ -729,11 +858,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
|
||||
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
|
||||
flashTail={8}
|
||||
glowColor={tint(theme.background.default, glowHue(), 0.1)}
|
||||
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
|
||||
outerGlowColor={theme.background.default}
|
||||
glowTail={8}
|
||||
outerGlowTail={5}
|
||||
completionColor={tint(theme.background.default, glowHue(), 0.1)}
|
||||
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
|
||||
outerCompletionColor={theme.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
@@ -755,14 +884,20 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth() + 1}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
<TabIndicator
|
||||
status={status()}
|
||||
label={sessionTabNumberLabel(index())}
|
||||
width={numberWidth()}
|
||||
color={numberColor()}
|
||||
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
|
||||
backgroundColor={pulseBackground()}
|
||||
flashColor={theme.text.default}
|
||||
animations={animations()}
|
||||
numbers={props.numbers}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{sessionTabNumberLabel(index()).padStart(numberWidth())}
|
||||
</text>
|
||||
/>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
fg={foreground()}
|
||||
@@ -923,11 +1058,16 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
)
|
||||
}
|
||||
|
||||
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
function HorizontalSessionTabs(props: {
|
||||
controller?: SessionTabsController
|
||||
animations?: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
numbers: boolean
|
||||
}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
@@ -960,10 +1100,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onCleanup(clearCloseHold)
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
@@ -1211,13 +1350,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
|
||||
// so it reads as a lift of the pulse color rather than a different hue.
|
||||
const flashColor = () => tint(background(), theme.text.default, 0.65)
|
||||
const feedbackColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return undefined
|
||||
}
|
||||
const glowColor = () => feedbackColor() ?? accent()
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const feedbackColor = () => tabFeedbackColor(status(), theme)
|
||||
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
|
||||
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
|
||||
const glows = () =>
|
||||
Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
@@ -1273,7 +1410,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), tint(theme.text.default, background(), 0.25), selection())
|
||||
const color = feedback ?? (runs() ? activeNumber() : tint(base, accent(), activity()))
|
||||
const color = runs() ? activeNumber() : (feedback ?? tint(base, unreadColor(), activity()))
|
||||
// The number brightens faintly as the running sweep passes beneath it.
|
||||
return tint(color, theme.text.default, Math.max(numberIgnition.value().level, 0.15 * sweepLevel()))
|
||||
}
|
||||
@@ -1327,14 +1464,25 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
color={pulseColor()}
|
||||
glowColor={glowColor()}
|
||||
flashColor={flashColor()}
|
||||
completionColor={accent()}
|
||||
completionColor={unreadColor()}
|
||||
backgroundColor={background()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
|
||||
</text>
|
||||
<TabIndicator
|
||||
status={status()}
|
||||
label={tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)}
|
||||
width={numberWidth()}
|
||||
color={numberColor()}
|
||||
unreadColor={feedbackColor() ?? unreadColor()}
|
||||
backgroundColor={background()}
|
||||
flashColor={theme.text.default}
|
||||
animations={animations()}
|
||||
numbers={props.numbers}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
attributes={bold()}
|
||||
/>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
fg={foreground()}
|
||||
|
||||
@@ -143,7 +143,8 @@ export function TerminalPane(props: {
|
||||
},
|
||||
{ priority: 100 },
|
||||
)
|
||||
const onFocused = () => props.onFocusChange?.(terminal?.focused === true)
|
||||
// Blur emits this event before updating the terminal's own focused flag.
|
||||
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
createEffect(() => {
|
||||
if (!props.autoFocus || !terminal) return
|
||||
|
||||
@@ -159,6 +159,9 @@ export const Info = Schema.Struct({
|
||||
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
||||
description: "Show tabs in a horizontal strip or vertical sidebar",
|
||||
}),
|
||||
indicators: Schema.optional(Schema.Literals(["status", "numbers"])).annotate({
|
||||
description: "Show status icons or always show tab numbers",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
@@ -231,6 +234,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
layout: "horizontal" | "vertical"
|
||||
indicators: "status" | "numbers"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +287,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
enabled: input.tabs?.enabled ?? true,
|
||||
scope: input.tabs?.scope ?? "cwd",
|
||||
layout: input.tabs?.layout ?? "horizontal",
|
||||
indicators: input.tabs?.indicators ?? "status",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,9 +167,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
? ("error" as const)
|
||||
: ("activity" as const),
|
||||
promptPulse: promptPulses()[session] ?? 0,
|
||||
attention: members.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
attention: members.some((id) => (data.session.permission.list(id)?.length ?? 0) > 0)
|
||||
? ("permission" as const)
|
||||
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
|
||||
? ("question" as const)
|
||||
: (false as const),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,26 +26,23 @@ export function StoryFooter(props: {
|
||||
</box>
|
||||
<Show when={props.status || props.message}>
|
||||
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
|
||||
<text fg={theme.text.default}>{props.status ?? ""}</text>
|
||||
<Show when={props.status && props.message}>
|
||||
<text fg={theme.text.subdued}> · </text>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{props.message ?? ""}</text>
|
||||
<text fg={theme.text.default} wrapMode="none">
|
||||
{props.status ?? ""}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
{props.status && props.message ? " · " : ""}
|
||||
{props.message ?? ""}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
|
||||
<text fg={theme.text.default} wrapMode="none">
|
||||
<For each={props.controls}>
|
||||
{(control, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span> </span>
|
||||
</Show>
|
||||
{control.shortcut} <span style={{ fg: theme.text.subdued }}>{control.label}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
<box paddingLeft={1} paddingRight={1} flexDirection="row" flexWrap="wrap" columnGap={1}>
|
||||
<For each={props.controls}>
|
||||
{(control) => (
|
||||
<text fg={theme.text.default} wrapMode="none" flexShrink={0}>
|
||||
{control.shortcut} <span style={{ fg: theme.text.subdued }}>{control.label}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
{/* The app-wide feature footer overlays the terminal's final row. */}
|
||||
<box height={1} />
|
||||
|
||||
@@ -2,7 +2,15 @@ import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createSignal, For } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
|
||||
import {
|
||||
EMPTY_SESSION_TAB_STATUS,
|
||||
SessionTabs,
|
||||
TAB_SPINNERS,
|
||||
TAB_UNREAD_MARKERS,
|
||||
type SessionTabsController,
|
||||
type TabSpinner,
|
||||
type TabUnreadMarker,
|
||||
} from "../../../component/session-tabs"
|
||||
import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
|
||||
import { StoryFooter } from "./footer"
|
||||
import type { Story } from "./index"
|
||||
@@ -24,6 +32,15 @@ const FIXTURE_TABS = [
|
||||
{ sessionID: "fixture-12", title: "Prepare review", project: "opencode-drive" },
|
||||
]
|
||||
|
||||
const FIXTURE_STATUSES: Record<string, FixtureStatus> = {
|
||||
"fixture-2": { ...EMPTY_SESSION_TAB_STATUS, busy: true },
|
||||
"fixture-3": { ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" },
|
||||
"fixture-4": { ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "permission" },
|
||||
"fixture-5": { ...EMPTY_SESSION_TAB_STATUS, unread: "activity" },
|
||||
"fixture-6": { ...EMPTY_SESSION_TAB_STATUS, unread: "error" },
|
||||
}
|
||||
const FIXTURE_OUTCOMES = { "fixture-5": "completed", "fixture-6": "failed" } as const
|
||||
|
||||
// Plausible targets for the fake transcript's tool calls, picked per fixture index.
|
||||
const TRANSCRIPT_FILES = [
|
||||
"packages/tui/src/component/session-tabs.tsx",
|
||||
@@ -45,23 +62,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
const setItems = (next: { sessionID: string; title?: string }[]) =>
|
||||
setTabStore("items", reconcile(next, { key: "sessionID" }))
|
||||
const [active, setActive] = createSignal<string | undefined>("fixture-1")
|
||||
const [lastEvent, setLastEvent] = createSignal("press space to start a random tab")
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({})
|
||||
const [lastEvent, setLastEvent] = createSignal("idle / working / question / permission / complete / error")
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>(FIXTURE_STATUSES)
|
||||
const [orientation, setOrientation] = createSignal<"horizontal" | "vertical">("vertical")
|
||||
const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[]
|
||||
const [spinner, setSpinner] = createSignal<TabSpinner>("dots")
|
||||
const markers = Object.keys(TAB_UNREAD_MARKERS) as TabUnreadMarker[]
|
||||
const [marker, setMarker] = createSignal<TabUnreadMarker>("small-dot")
|
||||
const [animations, setAnimations] = createSignal(true)
|
||||
// Unread clears on select, so the transcript remembers how each session's last run ended.
|
||||
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>({})
|
||||
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>(FIXTURE_OUTCOMES)
|
||||
const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1
|
||||
|
||||
function finishRun(sessionID: string) {
|
||||
function finishRun(sessionID: string, failed = Math.random() >= 0.75) {
|
||||
if (!tabs().some((item) => item.sessionID === sessionID)) return
|
||||
const roll = Math.random()
|
||||
const failed = roll >= 0.75
|
||||
const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const)
|
||||
batch(() => {
|
||||
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, attention: false, unread },
|
||||
}))
|
||||
// An untitled session earns its title after its first completed run, like a real summarization.
|
||||
const index = number(sessionID) - 1
|
||||
@@ -77,8 +97,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
const status = statuses()[sessionID]
|
||||
batch(() => {
|
||||
setActive(sessionID)
|
||||
if (status && (status.unread || status.attention))
|
||||
setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } }))
|
||||
if (status?.unread) setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined } }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,7 +153,12 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
const startRun = (sessionID: string) => {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
|
||||
[sessionID]: {
|
||||
...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS),
|
||||
busy: true,
|
||||
attention: false,
|
||||
unread: undefined,
|
||||
},
|
||||
}))
|
||||
setOutcomes((current) => {
|
||||
const next = { ...current }
|
||||
@@ -170,7 +194,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
return pool[Math.floor(Math.random() * pool.length)]
|
||||
}
|
||||
const randomRunningTab = () => {
|
||||
const candidates = tabs().filter((tab) => controller.status(tab.sessionID).busy)
|
||||
const candidates = tabs().filter((tab) => {
|
||||
const status = controller.status(tab.sessionID)
|
||||
return status.busy && !status.attention
|
||||
})
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]
|
||||
}
|
||||
// A fake transcript for the selected session so tab switches feel like moving between real
|
||||
@@ -190,7 +217,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
{ text: `> ${fixture.title}`, color: theme.text.default },
|
||||
{ text: "", color: theme.text.default },
|
||||
]
|
||||
if (!status.busy && outcome === undefined) {
|
||||
if (!status.busy && !status.attention && outcome === undefined) {
|
||||
lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued })
|
||||
return lines
|
||||
}
|
||||
@@ -202,7 +229,11 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
{ text: ` ✱ Bash bun run test`, color: theme.text.subdued },
|
||||
{ text: "", color: theme.text.default },
|
||||
)
|
||||
if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued })
|
||||
if (status.attention === "question")
|
||||
lines.push({ text: "? Which approach should I take?", color: theme.text.status.question })
|
||||
else if (status.attention === "permission")
|
||||
lines.push({ text: "! Waiting for permission to run the command", color: theme.text.status.permission })
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.status.running })
|
||||
else if (outcome === "failed")
|
||||
lines.push({
|
||||
text: `✗ bun run test failed — 3 tests failing in ${file}`,
|
||||
@@ -218,9 +249,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
|
||||
const stateSummary = () => {
|
||||
const values = tabs().map((tab) => controller.status(tab.sessionID))
|
||||
const running = values.filter((status) => status.busy).length
|
||||
const running = values.filter((status) => status.busy && !status.attention).length
|
||||
const waiting = values.filter((status) => status.attention).length
|
||||
const unread = values.filter((status) => status.unread !== undefined).length
|
||||
return [`selected ${number(active() ?? "")}`, `${running} running`, `${unread} unread`].join(" · ")
|
||||
return [`selected ${number(active() ?? "")}`, `${running} running`, `${waiting} waiting`, `${unread} unread`].join(
|
||||
" · ",
|
||||
)
|
||||
}
|
||||
|
||||
const reset = (showcase = false) => {
|
||||
batch(() => {
|
||||
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
|
||||
setStatuses(showcase ? FIXTURE_STATUSES : {})
|
||||
setOutcomes(showcase ? FIXTURE_OUTCOMES : {})
|
||||
setActive("fixture-1")
|
||||
setSpinner("dots")
|
||||
setMarker("small-dot")
|
||||
setAnimations(true)
|
||||
setOrientation("vertical")
|
||||
})
|
||||
setLastEvent(showcase ? "all six states are visible" : "reset; all tabs idle")
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
@@ -236,7 +284,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
{ bind: "up,k,left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) },
|
||||
{ bind: "down,j,right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) },
|
||||
...Array.from({ length: 10 }, (_, index) => ({
|
||||
bind: String((index + 1) % 10),
|
||||
bind: `${(index + 1) % 10},ctrl+${(index + 1) % 10}`,
|
||||
title: `Select tab ${index + 1}`,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
@@ -289,13 +337,64 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
if (controller.status(current).busy) {
|
||||
if (controller.status(current).busy && !controller.status(current).attention) {
|
||||
setLastEvent(`tab ${number(current)} is already running`)
|
||||
return
|
||||
}
|
||||
startRun(current)
|
||||
},
|
||||
},
|
||||
...(
|
||||
[
|
||||
{ bind: "q", title: "Ask a question", attention: "question" },
|
||||
{ bind: "a", title: "Request permission", attention: "permission" },
|
||||
{ bind: "i", title: "Set idle", attention: false },
|
||||
] as const
|
||||
).map((state) => ({
|
||||
bind: state.bind,
|
||||
title: state.title,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
startRun(current)
|
||||
setStatuses((statuses) => ({
|
||||
...statuses,
|
||||
[current]: { ...EMPTY_SESSION_TAB_STATUS, busy: Boolean(state.attention), attention: state.attention },
|
||||
}))
|
||||
setLastEvent(`tab ${number(current)} ${state.attention || "idle"}`)
|
||||
},
|
||||
})),
|
||||
...(
|
||||
[
|
||||
{ bind: "f", title: "Complete selected tab", failed: false },
|
||||
{ bind: "x", title: "Fail selected tab", failed: true },
|
||||
] as const
|
||||
).map((state) => ({
|
||||
bind: state.bind,
|
||||
title: state.title,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
// Leave the result unread so its indicator can be inspected before selecting it again.
|
||||
cycle(1)
|
||||
finishRun(current, state.failed)
|
||||
},
|
||||
})),
|
||||
{
|
||||
bind: "c",
|
||||
title: "Cycle spinner shape",
|
||||
group: "Storybook",
|
||||
run: () => setSpinner((value) => spinners[(spinners.indexOf(value) + 1) % spinners.length]),
|
||||
},
|
||||
{
|
||||
bind: "u",
|
||||
title: "Cycle unread marker",
|
||||
group: "Storybook",
|
||||
run: () => setMarker((value) => markers[(markers.indexOf(value) + 1) % markers.length]),
|
||||
},
|
||||
{ bind: "m", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
|
||||
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
|
||||
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
|
||||
{
|
||||
@@ -306,20 +405,8 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
setOrientation((value) => (value === "vertical" ? "horizontal" : "vertical"))
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "r",
|
||||
title: "Reset",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
batch(() => {
|
||||
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
|
||||
setStatuses({})
|
||||
setOutcomes({})
|
||||
setActive("fixture-1")
|
||||
})
|
||||
setLastEvent("reset; press space to start a random tab")
|
||||
},
|
||||
},
|
||||
{ bind: "r,shift+r", title: "Reset to idle", group: "Storybook", run: () => reset() },
|
||||
{ bind: "v", title: "Show all states", group: "Storybook", run: () => reset(true) },
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -331,7 +418,13 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box flexGrow={1} flexDirection={orientation() === "vertical" ? "row" : "column"}>
|
||||
<SessionTabs controller={controller} orientation={orientation()} />
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation()}
|
||||
spinner={spinner()}
|
||||
unreadMarker={marker()}
|
||||
animations={animations()}
|
||||
/>
|
||||
<box flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
|
||||
<For each={transcript()}>
|
||||
{(line) => (
|
||||
@@ -345,16 +438,27 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
<StoryFooter
|
||||
context={props.context}
|
||||
title="storybook / tabs"
|
||||
details={[orientation() === "vertical" ? "left rail" : "top strip"]}
|
||||
details={[
|
||||
orientation() === "vertical" ? "left rail" : "top strip",
|
||||
spinner(),
|
||||
`${TAB_UNREAD_MARKERS[marker()]} ${marker()}`,
|
||||
animations() ? "animated" : "still",
|
||||
]}
|
||||
status={stateSummary()}
|
||||
message={lastEvent()}
|
||||
controls={[
|
||||
{ shortcut: "space", label: "start" },
|
||||
{ shortcut: "p", label: "prompt" },
|
||||
{ shortcut: "e", label: "end" },
|
||||
{ shortcut: "s", label: "work" },
|
||||
{ shortcut: "q", label: "question" },
|
||||
{ shortcut: "a", label: "permission" },
|
||||
{ shortcut: "i", label: "idle" },
|
||||
{ shortcut: "f/x", label: "complete/fail" },
|
||||
{ shortcut: "c", label: "spinner" },
|
||||
{ shortcut: "u", label: "unread marker" },
|
||||
{ shortcut: "m", label: "motion" },
|
||||
{ shortcut: "↑/↓", label: "select" },
|
||||
{ shortcut: "o", label: "layout" },
|
||||
{ shortcut: "r", label: "reset" },
|
||||
{ shortcut: "r", label: "reset idle" },
|
||||
{ shortcut: "v", label: "all states" },
|
||||
{ shortcut: "esc", label: "back" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -177,11 +177,15 @@ export function createPluginContext(input: {
|
||||
tabs: {
|
||||
enabled: host.sessionTabs.enabled,
|
||||
list: () =>
|
||||
host.sessionTabs.tabs().map((tab) => ({
|
||||
...tab,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...host.sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
host.sessionTabs.tabs().map((tab) => {
|
||||
const status = host.sessionTabs.status(tab.sessionID)
|
||||
return {
|
||||
...tab,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...status,
|
||||
attention: Boolean(status.attention),
|
||||
}
|
||||
}),
|
||||
open(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
host.sessionTabs.select(sessionID)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { batch, createSignal } from "solid-js"
|
||||
import { ConfigProvider, useConfig, type Info } from "../../src/config"
|
||||
import {
|
||||
EMPTY_SESSION_TAB_STATUS,
|
||||
SessionTabs,
|
||||
type SessionTabsController,
|
||||
type SessionTabsStatus,
|
||||
} from "../../src/component/session-tabs"
|
||||
import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
|
||||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../src/context/session-tabs"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { ThemeProvider, useTheme } from "../../src/context/theme"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
test(`${orientation} tabs replace ordinals with status without moving titles`, async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
|
||||
const [active, setActive] = createSignal("second")
|
||||
const [animations, setAnimations] = createSignal(false)
|
||||
const [newTab, setNewTab] = createSignal(false)
|
||||
const settings: Info = { tabs: { enabled: true } }
|
||||
let config!: ReturnType<typeof useConfig>
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
function Colors() {
|
||||
config = useConfig()
|
||||
theme = orientation === "vertical" ? useTheme("elevated") : useTheme()
|
||||
return null
|
||||
}
|
||||
const controller = {
|
||||
tabs: () => [
|
||||
{ sessionID: "first", title: "First" },
|
||||
{ sessionID: "second", title: "Second" },
|
||||
],
|
||||
current: active,
|
||||
newTab,
|
||||
select(sessionID: string) {
|
||||
batch(() => {
|
||||
setActive(sessionID)
|
||||
if (sessionID === "first") setStatus((current) => ({ ...current, unread: undefined }))
|
||||
})
|
||||
},
|
||||
close() {},
|
||||
move() {},
|
||||
detail: () => "project",
|
||||
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
|
||||
} satisfies SessionTabsController
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider
|
||||
config={createTuiResolvedConfig(settings)}
|
||||
service={{
|
||||
get: async () => settings,
|
||||
update: async (update) => {
|
||||
update(settings)
|
||||
return settings
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RouteProvider initialRoute={{ type: "home" }}>
|
||||
<ClientProvider api={createApi(createFetch(undefined, createEventStream()).fetch)}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Colors />
|
||||
<SessionTabs controller={controller} orientation={orientation} animations={animations()} />
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 60, height: 10 },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes(" First") && frame.includes(" Second"))
|
||||
|
||||
const titleColumn = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes("First"))!
|
||||
.indexOf("First")
|
||||
const states: { status: Partial<SessionTabsStatus>; label: string }[] = [
|
||||
{ status: { busy: true }, label: SPINNER_FRAMES[0] },
|
||||
{ status: { busy: true, attention: "question" }, label: "?" },
|
||||
{ status: { busy: true, attention: "permission" }, label: "!" },
|
||||
{ status: { unread: "activity" }, label: "\u2022" },
|
||||
{ status: { unread: "error" }, label: "\u2022" },
|
||||
{ status: {}, label: "" },
|
||||
]
|
||||
for (const state of states) {
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, ...state.status })
|
||||
await app.renderOnce()
|
||||
await app.waitForFrame((frame) => frame.includes(`${state.label.padStart(2)} First`))
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
expect(rows.find((line) => line.includes("First"))!.indexOf("First")).toBe(titleColumn)
|
||||
expect(rows[orientation === "vertical" ? 2 : 1]?.trim()).toBe(orientation === "vertical" ? "project" : "")
|
||||
}
|
||||
|
||||
for (const attention of ["question", "permission"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
|
||||
await app.renderOnce()
|
||||
const glow = () => {
|
||||
const colors = app
|
||||
.captureSpans()
|
||||
.lines[
|
||||
orientation === "vertical" ? 1 : 0
|
||||
]!.spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg))
|
||||
return (
|
||||
Math.abs(colors[1]!.r - colors[18]!.r) +
|
||||
Math.abs(colors[1]!.g - colors[18]!.g) +
|
||||
Math.abs(colors[1]!.b - colors[18]!.b)
|
||||
)
|
||||
}
|
||||
const full = glow()
|
||||
expect(full).toBeGreaterThan(0)
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
|
||||
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const glyph = "\u2022"
|
||||
for (const unread of ["activity", "error"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
|
||||
await app.renderOnce()
|
||||
const color = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color()?.toInts()).toEqual(
|
||||
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
|
||||
)
|
||||
const brightness = () => {
|
||||
const value = color()
|
||||
return value ? value.r + value.g + value.b : undefined
|
||||
}
|
||||
const initial = brightness()!
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
await app.renderOnce()
|
||||
expect(active()).toBe("first")
|
||||
expect(status().unread).toBeUndefined()
|
||||
expect(app.captureCharFrame()).toContain(`${glyph} First`)
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
|
||||
const peak = brightness()!
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
|
||||
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
|
||||
}
|
||||
|
||||
setAnimations(false)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
|
||||
await app.waitForFrame((frame) => frame.includes("? First"))
|
||||
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "numbers"
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("1 First")
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "status"
|
||||
})
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
|
||||
|
||||
setNewTab(true)
|
||||
await app.waitForFrame((frame) => frame.includes("+ New session"))
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -20,9 +20,10 @@ test("validates mini replay settings", () => {
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical", indicators: "numbers" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical", indicators: "numbers" },
|
||||
})
|
||||
expect(() => decode({ tabs: { indicators: "unknown" } })).toThrow()
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
@@ -49,7 +50,7 @@ test("resolves nested config and keybind defaults", () => {
|
||||
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||
expect(config.diffs).toEqual({ view: "split" })
|
||||
expect(config.debug).toEqual({ devtools: true })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", indicators: "status" })
|
||||
expect(config.session.new_location).toBe("launch")
|
||||
expect(config.session.tps).toBe(true)
|
||||
})
|
||||
@@ -58,6 +59,10 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.indicators")).toMatchObject({
|
||||
default: "status",
|
||||
values: ["status", "numbers"],
|
||||
})
|
||||
})
|
||||
|
||||
test("shows the new session location default in settings", () => {
|
||||
|
||||
@@ -753,6 +753,64 @@ test("ignores subagent unread state on the root tab", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("distinguishes family questions and permissions without clearing them on selection", async () => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
home: true,
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
})
|
||||
try {
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
expect(setup.tabs.status("root").attention).toBe(false)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_question",
|
||||
created: 1,
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID: "child",
|
||||
title: "Choose an approach",
|
||||
fields: [{ key: "approach", type: "string", title: "Approach" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === "question")
|
||||
|
||||
setup.tabs.select("root")
|
||||
await wait(() => setup.tabs.current() === "root")
|
||||
expect(setup.tabs.status("root").attention).toBe("question")
|
||||
|
||||
setup.emit({
|
||||
id: "evt_permission",
|
||||
created: 2,
|
||||
type: "permission.asked",
|
||||
data: { id: "per_command", sessionID: "root", action: "shell", resources: ["bun run test"] },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === "permission")
|
||||
expect(setup.tabs.status("child").attention).toBe("permission")
|
||||
|
||||
setup.emit({
|
||||
id: "evt_permission_reply",
|
||||
created: 3,
|
||||
type: "permission.replied",
|
||||
data: { sessionID: "root", requestID: "per_command", reply: "once" },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === "question")
|
||||
|
||||
setup.emit({
|
||||
id: "evt_question_reply",
|
||||
created: 4,
|
||||
type: "form.replied",
|
||||
data: { sessionID: "child", id: "frm_question", answer: {} },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === false)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const state = temporary.path
|
||||
|
||||
@@ -118,9 +118,15 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
|
||||
? unknown
|
||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||
|
||||
type CheckReplacementOutputs<SourceOutput, ReplacementOutput> = [Exclude<SourceOutput, ReplacementOutput>] extends [
|
||||
never,
|
||||
]
|
||||
? unknown
|
||||
: { readonly "Missing replacement outputs": Exclude<SourceOutput, ReplacementOutput> }
|
||||
|
||||
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
|
||||
? Replacement extends Node<NoInfer<A>, infer E2, T>
|
||||
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||
? Replacement extends Node<infer A2, infer E2, T>
|
||||
? CheckReplacementOutputs<A, NoInfer<A2>> & CheckReplacementErrors<E, NoInfer<E2>>
|
||||
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
|
||||
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||
: { readonly "Invalid replacement": Replacement }
|
||||
@@ -218,29 +224,42 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
|
||||
} {
|
||||
const hoisted = new Map<string, AnyNode>()
|
||||
const replacementMap = replacementMapFrom(replacements)
|
||||
|
||||
const node = walk<AnyNode>(
|
||||
const definitions = new Map<string, AnyNode>()
|
||||
// Validate the entire effective closure, including dependencies below hoisted roots.
|
||||
const effective = walk<AnyNode>(
|
||||
root,
|
||||
(node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
if (node.tag === tag) {
|
||||
const existing = hoisted.get(node.name)
|
||||
if (existing && existing.implementation !== node.implementation) {
|
||||
const dependencies = node.dependencies.map(context.visit)
|
||||
const result = dependencies.every((dependency, index) => dependency === node.dependencies[index])
|
||||
? node
|
||||
: { ...node, dependencies }
|
||||
if (node.kind !== "group" && node.tag === tag) {
|
||||
const existing = definitions.get(node.name)
|
||||
if (existing && !sameDefinition(existing, result)) {
|
||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||
}
|
||||
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))
|
||||
return group([])
|
||||
if (existing) return existing
|
||||
definitions.set(node.name, result)
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
return result
|
||||
},
|
||||
{ resolve: (node) => replacementMap.get(node.name) ?? node },
|
||||
{ resolve: (node) => resolveReplacement(node, replacementMap) },
|
||||
)
|
||||
|
||||
const node = walk<AnyNode>(effective, (node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
if (node.tag === tag) {
|
||||
hoisted.set(node.name, node)
|
||||
return group([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
})
|
||||
|
||||
return {
|
||||
node: node as Node<A, E>,
|
||||
hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
|
||||
@@ -264,7 +283,7 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
|
||||
? implementation
|
||||
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||
},
|
||||
{ cache, resolve: (node) => replacementMap.get(node.name) ?? node },
|
||||
{ cache, resolve: (node) => resolveReplacement(node, replacementMap) },
|
||||
)
|
||||
const layers = flatten(root).map((node) => compileNode(node))
|
||||
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||
@@ -272,58 +291,51 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
|
||||
}
|
||||
|
||||
function replacementMapFrom(replacements?: Replacements) {
|
||||
// Resolve dependencies only after the last override wins, not in intermediate graphs.
|
||||
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
|
||||
}
|
||||
|
||||
function resolveReplacement(node: AnyNode, replacements: ReturnType<typeof replacementMapFrom>) {
|
||||
const replacement = replacements.get(node.name)
|
||||
if (!replacement) return node
|
||||
if (node.tag !== replacement.tag) {
|
||||
throw new Error(`Cannot replace ${node.name} across tags`)
|
||||
}
|
||||
return replacement
|
||||
}
|
||||
|
||||
function sameDefinition(left: AnyNode, right: AnyNode): boolean {
|
||||
if (left === right) return true
|
||||
if (
|
||||
left.kind !== right.kind ||
|
||||
left.name !== right.name ||
|
||||
left.tag !== right.tag ||
|
||||
left.implementation !== right.implementation
|
||||
)
|
||||
return false
|
||||
const leftDependencies = left.dependencies.flatMap(flatten)
|
||||
const rightDependencies = right.dependencies.flatMap(flatten)
|
||||
return (
|
||||
replacements?.reduce((map, [source, replacement]) => {
|
||||
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
|
||||
const current = new Map([[source.name, normalized]])
|
||||
for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
|
||||
map.set(source.name, normalized)
|
||||
return map
|
||||
}, new Map<string, AnyNode>()) ?? new Map<string, AnyNode>()
|
||||
leftDependencies.length === rightDependencies.length &&
|
||||
leftDependencies.every((dependency, index) => sameDefinition(dependency, rightDependencies[index]))
|
||||
)
|
||||
}
|
||||
|
||||
function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
|
||||
if (replacements.size === 0) return root
|
||||
const cache = new Map<AnyNode, AnyNode>()
|
||||
const visiting = new Set<AnyNode>()
|
||||
const stack: AnyNode[] = []
|
||||
|
||||
const recur = (node: AnyNode, isRoot = false): AnyNode => {
|
||||
const target = isRoot ? node : (replacements.get(node.name) ?? node)
|
||||
const cached = cache.get(target)
|
||||
if (cached !== undefined || cache.has(target)) return cached!
|
||||
if (visiting.has(target)) {
|
||||
const start = stack.indexOf(target)
|
||||
throw new Error(
|
||||
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
visiting.add(target)
|
||||
stack.push(target)
|
||||
try {
|
||||
const dependencies = target.dependencies.map((dependency) => recur(dependency))
|
||||
const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
|
||||
? target
|
||||
: { ...target, dependencies }
|
||||
cache.set(target, result)
|
||||
return result
|
||||
} finally {
|
||||
stack.pop()
|
||||
visiting.delete(target)
|
||||
}
|
||||
}
|
||||
|
||||
return recur(root, true)
|
||||
}
|
||||
|
||||
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||
export function hasUnbound<const Items extends Replacements = readonly []>(
|
||||
root: Node<unknown, unknown, any>,
|
||||
source: AnyNode,
|
||||
replacements?: ValidReplacements<Items>,
|
||||
): boolean {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
||||
return walk<boolean>(root, (node, context) => {
|
||||
if (node === source) return true
|
||||
return node.dependencies.some(context.visit)
|
||||
})
|
||||
const replacementMap = replacementMapFrom(replacements)
|
||||
return walk<boolean>(
|
||||
root,
|
||||
(node, context) => {
|
||||
if (node === source) return true
|
||||
return node.dependencies.some(context.visit)
|
||||
},
|
||||
{ resolve: (node) => resolveReplacement(node, replacementMap) },
|
||||
)
|
||||
}
|
||||
|
||||
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||
|
||||
@@ -533,32 +533,43 @@ interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellknown"> {
|
||||
|
||||
### MCP
|
||||
|
||||
List servers and change connection state.
|
||||
List MCP servers and their current connection state.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = ctx.mcp
|
||||
const servers = yield* mcp.list().pipe(Effect.orDie)
|
||||
yield* mcp.connect({ server: "docs" }).pipe(Effect.orDie)
|
||||
yield* mcp.disconnect({ server: "docs" }).pipe(Effect.orDie)
|
||||
yield* Effect.logInfo("MCP servers", { count: servers.data.length })
|
||||
}),
|
||||
```
|
||||
|
||||
Add servers through the API, or transform and reload their configuration.
|
||||
Plugins manage MCP servers only through transforms. Use `draft.set` to add or replace a server, `draft.update` to
|
||||
change its configuration, and `draft.remove` to remove it. Inspect configuration with `draft.list` and `draft.get`.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = ctx.mcp
|
||||
yield* mcp
|
||||
.add({ server: "docs", config: { type: "remote", url: "https://mcp.example.com" } })
|
||||
.pipe(Effect.orDie)
|
||||
yield* mcp.transform((draft) => {
|
||||
const servers = draft.list()
|
||||
const docs = draft.get("docs")
|
||||
draft.set("docs", { type: "remote", url: "https://mcp.example.com" })
|
||||
draft.update("docs", (server) => (server.disabled = false))
|
||||
draft.remove("legacy")
|
||||
})
|
||||
}),
|
||||
```
|
||||
|
||||
Set `disabled: true` in a transform to disable a server and disconnect it, or `disabled: false` to enable it and allow
|
||||
OpenCode to connect. OpenCode reconciles server lifecycle from the transformed configuration.
|
||||
|
||||
Call `reload()` after external state used by a transform changes to reapply transforms and reconcile the servers.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const mcp = ctx.mcp
|
||||
yield* mcp.reload()
|
||||
}),
|
||||
```
|
||||
@@ -575,7 +586,7 @@ interface MCPDraft {
|
||||
remove(name: string): void
|
||||
}
|
||||
|
||||
interface MCPDomain extends Omit<McpApi<unknown>, "resource"> {
|
||||
interface MCPDomain extends Pick<McpApi<unknown>, "list"> {
|
||||
readonly transform: Transform<MCPDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -847,23 +858,13 @@ interface ToolDraft {
|
||||
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
|
||||
tool: Tool.Info<Input, Output>,
|
||||
): void
|
||||
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
|
||||
remove(id: string): void
|
||||
}
|
||||
|
||||
interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
`update` and `remove` use the effective registry name, such as `acme_greeting` above, and do nothing for missing tools.
|
||||
Creating a tool requires `add`, not an agent-style upsert. Updates preserve the name and namespace; assign new schemas
|
||||
and options to replace them.
|
||||
|
||||
As with agents and skills, transforms replay in registration order. Use `yield* ctx.tool.reload()` after external state
|
||||
changes. `yield* registration.dispose` removes that transform and rebuilds the tools. Scope cleanup does the same.
|
||||
|
||||
### VCS
|
||||
|
||||
Read repository information, working-copy status, or file diffs.
|
||||
|
||||
@@ -72,6 +72,19 @@ setup(ctx) {
|
||||
}
|
||||
```
|
||||
|
||||
`ctx.location` describes the location where this plugin instance is loaded. It
|
||||
includes `directory`, optional `workspaceID`, and `project` metadata (`id`,
|
||||
`directory`, and `canonical`). It is available in both Promise and Effect plugins.
|
||||
This is the plugin instance's location, not the location of every session it can
|
||||
access or event it receives.
|
||||
|
||||
```ts
|
||||
setup(ctx) {
|
||||
console.log(ctx.location.directory)
|
||||
console.log(ctx.location.project.canonical)
|
||||
}
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
Pass plugin options with the object form in `opencode.json(c)`.
|
||||
@@ -488,25 +501,14 @@ interface IntegrationDraft {
|
||||
|
||||
### MCP
|
||||
|
||||
List MCP servers or change their connection state.
|
||||
List MCP servers and their current connection state.
|
||||
|
||||
```ts
|
||||
const servers = await ctx.mcp.list()
|
||||
await ctx.mcp.connect({ server: "docs" })
|
||||
await ctx.mcp.disconnect({ server: "docs" })
|
||||
```
|
||||
|
||||
Add or remove a server through the connected API.
|
||||
|
||||
```ts
|
||||
await ctx.mcp.add({
|
||||
server: "docs",
|
||||
config: { type: "remote", url: "https://mcp.example.com" },
|
||||
})
|
||||
await ctx.mcp.remove({ server: "legacy" })
|
||||
```
|
||||
|
||||
Register a transform to inspect, set, update, or remove server configuration.
|
||||
Plugins manage MCP servers only through transforms. Use `draft.set` to add or replace a server, `draft.update` to
|
||||
change its configuration, and `draft.remove` to remove it. Inspect configuration with `draft.list` and `draft.get`.
|
||||
|
||||
```ts
|
||||
await ctx.mcp.transform((draft) => {
|
||||
@@ -520,7 +522,10 @@ await ctx.mcp.transform((draft) => {
|
||||
})
|
||||
```
|
||||
|
||||
Reload MCP configuration after external state used by a transform changes.
|
||||
Set `disabled: true` in a transform to disable a server and disconnect it, or `disabled: false` to enable it and allow
|
||||
OpenCode to connect. OpenCode reconciles server lifecycle from the transformed configuration.
|
||||
|
||||
Call `reload()` after external state used by a transform changes to reapply transforms and reconcile the servers.
|
||||
|
||||
```ts
|
||||
await ctx.mcp.reload()
|
||||
@@ -534,10 +539,6 @@ Schemas: [`Mcp.Server`](/api#schema-Mcp.Server), [`Mcp.LocalConfigEncoded`](/api
|
||||
```ts
|
||||
interface MCPContext {
|
||||
list(input?: McpListInput, requestOptions?: RequestOptions): Promise<McpListOutput>
|
||||
add(input: McpAddInput, requestOptions?: RequestOptions): Promise<void>
|
||||
remove(input: McpRemoveInput, requestOptions?: RequestOptions): Promise<void>
|
||||
connect(input: McpConnectInput, requestOptions?: RequestOptions): Promise<void>
|
||||
disconnect(input: McpDisconnectInput, requestOptions?: RequestOptions): Promise<void>
|
||||
transform(callback: (draft: MCPDraft) => void): Promise<Registration>
|
||||
reload(): Promise<void>
|
||||
}
|
||||
@@ -783,7 +784,7 @@ interface StorageScanResult {
|
||||
|
||||
### Tools
|
||||
|
||||
Register, update, and remove tools with a transform.
|
||||
Register tools with a transform.
|
||||
|
||||
```ts
|
||||
await ctx.tool.transform((draft) => {
|
||||
@@ -802,22 +803,9 @@ await ctx.tool.transform((draft) => {
|
||||
return { content: `Hello ${(input as { name: string }).name}!` }
|
||||
},
|
||||
})
|
||||
draft.update("acme_greeting", (tool) => {
|
||||
tool.description = "Greet someone by name"
|
||||
})
|
||||
draft.remove("legacy")
|
||||
})
|
||||
```
|
||||
|
||||
`update` and `remove` use effective registry names, including the namespace: `acme_greeting` in the example above.
|
||||
Dots in namespaces and unsupported characters in tool names become `_`.
|
||||
|
||||
`update` does nothing when the ID is missing. Unlike agent upserts, creating a tool requires `add` with a complete
|
||||
definition. Updates preserve the tool's name and namespace; replace its schemas and options by assigning new values.
|
||||
As with agents and skills, transforms replay in registration order when registered, reloaded, or disposed. Later
|
||||
transforms see earlier changes. Call `await ctx.tool.reload()` after external state used by a transform changes.
|
||||
Calling `await registration.dispose()` removes that transform and rebuilds the tools; plugin unload does the same.
|
||||
|
||||
#### Reference
|
||||
|
||||
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
|
||||
@@ -826,13 +814,10 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s
|
||||
```ts
|
||||
interface ToolContext {
|
||||
transform(callback: (draft: ToolDraft) => void): Promise<Registration>
|
||||
reload(): Promise<void>
|
||||
}
|
||||
|
||||
interface ToolDraft {
|
||||
add(tool: ToolInfo): void
|
||||
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
|
||||
remove(id: string): void
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -156,6 +156,20 @@ Configure diff presentation:
|
||||
| `single` | boolean | Shows only the selected file patch. |
|
||||
| `view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses based on the available width. |
|
||||
|
||||
## Tabs
|
||||
|
||||
Choose **Tabs > Indicators** in settings, or set `tabs.indicators` in `cli.json`:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"tabs": {
|
||||
"indicators": "numbers"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` (the default) shows status icons. `numbers` always shows tab numbers, retaining their status colors and background animations. Both modes work with horizontal and vertical tabs. Ctrl+number shortcuts switch tabs in either mode.
|
||||
|
||||
## Terminal
|
||||
|
||||
Configure terminal integration:
|
||||
|
||||
Reference in New Issue
Block a user