Compare commits

..
Author SHA1 Message Date
Kit Langton e1237af0e1 refactor(core): centralize application construction 2026-08-26 22:09:09 -04:00
Kit Langton ecbe2fa7ea fix(util): validate effective layer graphs 2026-08-26 22:08:49 -04:00
Kit Langton 66f326ddec fix(tui): restore default Kitty keyboard reporting (#45423) 2026-08-26 21:41:19 -04:00
opencode-agent[bot]andBrendonovich 83b81b39f1 fix(app): remove worktree user message accent (#45250)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 09:36:35 +08:00
Brendan Allan b20e4971db test(app): run CI e2e against production builds (#45422) 2026-08-27 09:28:23 +08:00
James Long c3378f2d34 fix(pty): upgrade opencode-pty to 0.1.12 (#45420) 2026-08-26 21:15:09 -04:00
Kit Langton d68031bc0d fix(core): preserve renamed files when undoing changes (#45383) 2026-08-26 20:42:40 -04:00
Kit Langton 4ac19d744a feat(tui): make session tab status explicit (#45347) 2026-08-26 20:12:15 -04:00
Aiden Cline b5cb5e2a55 refactor(plugin): manage MCP servers through transforms (#45408) 2026-08-26 18:42:28 -05:00
Luke Parker 53d171781a fix(app): reduce catalog retention and status animation work (#45406) 2026-08-26 23:30:49 +00:00
opencode-agent[bot]andthdxr 3184427c03 feat(plugin): expose the current location in plugin context (#45403)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-26 19:09:16 -04:00
James Long 6b0613ac6f fix(tui): restore session focus during permission prompts (#45391) 2026-08-26 19:02:56 -04:00
Aiden Cline 5ac4156eb2 fix(core): preserve reasoning across model switches (#45401) 2026-08-26 17:52:41 -05:00
Luke Parker 954cdc7bc8 fix(desktop): restore compact Windows titlebar spacing (#45397) 2026-08-26 22:18:05 +00:00
97 changed files with 2485 additions and 1020 deletions
+6
View File
@@ -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.
+6
View File
@@ -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.
+5
View File
@@ -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.
+2 -2
View 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
+9 -9
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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(() => {})
+3 -1
View File
@@ -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 })
}
+1
View File
@@ -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",
+23 -12
View File
@@ -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",
+1 -1
View File
@@ -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)]
`}
>
+1 -1
View File
@@ -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 {
+2 -4
View File
@@ -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>
)
+1 -1
View File
@@ -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={{
+4 -1
View File
@@ -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={{
+1 -1
View File
@@ -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>
}
+8 -1
View File
@@ -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
+2 -1
View File
@@ -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()
})
})
+1 -1
View File
@@ -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:*",
+1 -1
View File
@@ -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:*",
+108
View File
@@ -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,
)
}),
)
}
+34
View File
@@ -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
+1 -6
View File
@@ -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"
+2
View File
@@ -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,
+9 -22
View File
@@ -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: {
+1 -18
View File
@@ -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
View File
@@ -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,
+5 -6
View File
@@ -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.
+85 -79
View File
@@ -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
}),
)
+146
View File
@@ -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,
+31
View File
@@ -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(
+4 -2
View File
@@ -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"),
},
})
+73 -36
View File
@@ -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>()
+31 -12
View File
@@ -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"])
}),
)
+10 -5
View File
@@ -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 ?? {
-1
View File
@@ -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.
+39 -74
View File
@@ -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")
}),
)
})
+117
View File
@@ -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) => {
+4 -5
View File
@@ -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],
+3 -4
View File
@@ -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
+1
View File
@@ -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"
+1 -1
View File
@@ -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>
}
+2
View File
@@ -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
+1 -5
View File
@@ -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>
}
+1 -25
View File
@@ -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,
+1
View File
@@ -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"
+1 -1
View File
@@ -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>
}
+2
View File
@@ -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
+1 -5
View File
@@ -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",
+2 -35
View File
@@ -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
+4 -86
View File
@@ -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)
})
+1
View File
@@ -21,6 +21,7 @@
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+12
View File
@@ -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" },
+4 -2
View File
@@ -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: {
+1 -1
View File
@@ -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({
+8
View File
@@ -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),
+6
View File
@@ -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: {
+42
View File
@@ -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",
+11 -6
View File
@@ -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>
+201 -53
View File
@@ -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()}
+2 -1
View File
@@ -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
+5
View File
@@ -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",
},
}
}
+5 -3
View File
@@ -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" },
]}
/>
+9 -5
View File
@@ -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()
}
})
}
+8 -3
View File
@@ -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
+77 -65
View File
@@ -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: