Compare commits

...
Author SHA1 Message Date
David Hill 963770f2ef fix(app): scroll overflowing composer controls 2026-09-04 23:24:19 -06:00
opencode-agent[bot]andHona 41cb354c3e fix(app): preserve vertical navigation label and icon widths (#47418)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 11:30:12 +10:00
James Long 23f3f8b6ca fix(tui): reference update command (#47415) 2026-09-04 20:43:44 -04:00
Aiden Cline baab05727d fix(core): compare Codex GPT versions by major and minor (#47404) 2026-09-04 19:22:12 -05:00
Aiden Cline 541937d124 fix(core): clarify how to call search in Code Mode guidance (#47360) 2026-09-04 18:50:47 -05:00
David Hill 0da55f1cef fix(app): list OpenCode Go before Zen (#47386) 2026-09-05 09:38:59 +10:00
opencode-agent[bot] ac76382211 chore: update nix node_modules hashes 2026-09-04 23:23:36 +00:00
James Long c30285c148 feat(worktree): support configurable plugin strategies (#47358) 2026-09-04 19:07:40 -04:00
opencode-agent[bot] b274224af1 fix(app): keep pending worktree composer styling consistent (#47377) 2026-09-04 22:59:21 +00:00
opencode-agent[bot] 7a050a19a1 fix(app): apply worktree message color during preparation (#47379) 2026-09-04 22:53:43 +00:00
Luke Parker 16601775f1 fix(app): align desktop worktree location with TUI (#47370) 2026-09-04 22:12:15 +00:00
James Long 0991e8b5a5 fix(tui): clarify update notifications (#47372) 2026-09-04 18:03:12 -04:00
James Long 6d791dfe67 feat(tui): add plugin-owned session panels (#47150) 2026-09-04 17:37:45 -04:00
Dax f268956c75 fix(tui): preview always-allow patterns without extra confirmation (#47369) 2026-09-04 21:34:58 +00:00
James Long 3290a39667 feat(tui): add manual update dialog (#47364) 2026-09-04 17:11:35 -04:00
James Long 51d69b26a0 refactor(tui): support reactive theme contexts (#47148) 2026-09-04 16:42:07 -04:00
James Long 8ff1ef1a62 fix(tui): anchor diff file menus to the screen (#47147) 2026-09-04 16:23:16 -04:00
James Long 90cd910f52 feat(tui): add shared interactivity context (#47144) 2026-09-04 16:07:11 -04:00
Dax Raad c3342ca812 chore: empty commit 2026-09-04 14:23:12 -04:00
Kit Langton 7ca047b2b9 fix(core): prioritize compaction over pending steers (#47340) 2026-09-04 13:23:55 -04:00
opencode-agent[bot]andnexxeln b78c2ea7b4 fix(tui): undo pending prompts and active sessions (#47343)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 22:52:03 +05:30
James Long 0e143437c8 feat(cli): restore automatic update policy (#47161) 2026-09-04 12:58:30 -04:00
opencode-agent[bot]andrekram1-node bff58fc387 feat(tui): show retry countdown (#47145)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-04 11:24:04 -05:00
Shoubhit Dash a26a978051 fix(ai): tolerate out-of-band responses notifications (#47316) 2026-09-04 21:36:29 +05:30
opencode-agent[bot]andnexxeln 218a0dde97 fix(tui): clear stale error styling on resumed subagents (#47295)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 21:29:27 +05:30
136 changed files with 5297 additions and 1383 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
+2
View File
@@ -695,6 +695,8 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
}
}
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
// Keepalives and provider notifications carry no response state and may precede response.created.
if (!event.type.startsWith("response.")) return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
it.effect("tolerates keepalive and provider notifications before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
@@ -1,5 +1,127 @@
import { expect, story } from "../../storybook/playwright/story"
for (const direction of ["ltr", "rtl"]) {
for (const alternate of ["none", "queue", "steer"]) {
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--toolbar-overflow", {
args: { alternate },
globals: { direction },
})
const controls = component.locator('[data-slot="composer-controls"]')
const actions = component.locator('[data-slot="composer-actions"]')
const submit = component.locator('[data-action="composer-submit"]')
const add = component.locator('[data-action="composer-attach"]')
const agent = controls.getByRole("button", { name: "Choose agent" })
const variant = controls.getByRole("button", { name: "Choose model variant" })
await expect(controls).toHaveCSS("overflow-x", "auto")
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
await expect(controls).toHaveCSS("direction", direction)
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
alternate === "none" ? 0 : 1,
)
for (const width of [1024, 360]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeGreaterThan(0)
const fixed = await submit.boundingBox()
const fixedAdd = await add.boundingBox()
const viewport = await controls.boundingBox()
const action = await actions.boundingBox()
expect(fixed).not.toBeNull()
expect(viewport).not.toBeNull()
expect(action).not.toBeNull()
expect(fixedAdd).not.toBeNull()
if (!fixed || !viewport || !action || !fixedAdd) return
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
1,
)
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
direction === "ltr" ? action.x - 12 : viewport.x - 12,
1,
)
await controls.evaluate((element) => {
element.scrollLeft = 0
})
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
await expect(controls).toHaveCSS(
"mask-image",
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
)
const first = await agent.boundingBox()
if (!first) throw new Error("Missing agent control")
expect(
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
).toBeCloseTo(0, 0)
await controls.evaluate((element) => {
element.scrollLeft =
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
const scrolled = (await controls.boundingBox())!
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
await controls.hover()
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
expect(await submit.boundingBox()).toEqual(fixed)
expect(await add.boundingBox()).toEqual(fixedAdd)
// The fade disappears at the endpoint instead of reserving padding.
await variant.focus()
await controls.evaluate((element) => {
element.scrollLeft =
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
const last = await variant.boundingBox()
if (!last) throw new Error("Missing variant control")
expect(
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
).toBeLessThan(1)
expect(
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
).toBeLessThan(1)
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(variant).toBeFocused()
await expect(submit).toBeInViewport()
await expect(add).toBeInViewport()
await add.click()
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
await page.keyboard.press("Escape")
expect(await add.boundingBox()).toEqual(fixedAdd)
}
if (alternate !== "none") {
const width = (await controls.boundingBox())!.width
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
}
})
}
}
story("does not mask or pad controls when they fit", async ({ mount }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const controls = component.locator('[data-slot="composer-controls"]')
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
await expect(controls).toHaveCSS("mask-image", "none")
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
@@ -490,20 +490,24 @@ async function openDraft(
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) {
if (path === "/api/worktree") {
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
await page.route(`**/api/worktree/${projectID}`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
},
)
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: Record<string, unknown> = route.request().postDataJSON()
@@ -87,16 +87,24 @@ test("recording a new session shortcut stays in settings until recording finishe
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await inventory.promise
await route.fallback()
},
)
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
const requested = page.waitForRequest(
(request) =>
new URL(request.url()).pathname === "/api/worktree" &&
new URL(request.url()).searchParams.get("location[directory]") === directory &&
request.method() === "GET",
)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
@@ -110,16 +118,57 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await refresh.promise
await route.fallback()
},
)
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: [
{ directory },
...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })),
],
})
}
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE",
)
await remove.click()
const request = await deleting
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
@@ -232,6 +232,74 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
for (const count of [0, 26]) {
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, directory, count }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: count }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_navigation_${index}`,
})),
),
)
},
{ server, directory: sessionA.directory, count },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
for (const width of [260, 180, 130]) {
if (width !== 260) {
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
const bounds = await sidebar.boundingBox()
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
await page.mouse.up()
}
await expect(sidebar).toHaveCSS("width", `${width}px`)
await testInfo.attach(`navigation-${count}-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
for (const name of ["Home", "New session"]) {
const button = sidebar.getByRole("button", { name, exact: true })
const label = button.getByText(name, { exact: true })
await expect(label).toBeVisible()
await expect
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
.toBeLessThanOrEqual(1)
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await button.hover()
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await expect
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.getByRole("main").hover()
}
}
})
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
@@ -359,9 +427,9 @@ for (const profile of [
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect(hint).toBeVisible()
await expect
.poll(() =>
hint.evaluate((element) => {
@@ -373,7 +441,7 @@ for (const profile of [
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
@@ -381,10 +449,10 @@ for (const profile of [
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
})
}
@@ -97,7 +97,9 @@ for (const theme of ["light", "dark"] as const) {
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
@@ -199,10 +201,13 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
events: () => events.splice(0),
})
// Keep authoritative inventory independent of the raw project's empty sandboxes.
await page.route(`**/api/worktree/${projectID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
},
)
if (draft)
await page.addInitScript(
({ root, server }) => {
@@ -222,7 +227,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
)
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
await page.goto(
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
+4 -8
View File
@@ -71,27 +71,23 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
success: NoContent,
}),
)
+37 -5
View File
@@ -55,6 +55,8 @@ function ComposerStory(props: {
label?: string
inspectRequest?: boolean
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -66,11 +68,15 @@ function ComposerStory(props: {
activity: props.label ?? "Ready",
variant: STORY_MODEL.variant,
})
const modelOption = createMemo(() => ({
...selectedModel,
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
}))
const modelSelection = {
ready: Object.assign(() => true, { promise: undefined }),
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
current: () => modelOption(),
recent: () => [modelOption()],
list: () => [modelOption()],
cycle() {},
set() {},
visible: () => true,
@@ -126,7 +132,7 @@ function ComposerStory(props: {
placeholder: () => "Ask anything, / for commands, @ for context...",
agent: {
options: () => [
{ id: "build", label: "build" },
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
{ id: "review", label: "review" },
],
current: () => "build",
@@ -135,7 +141,7 @@ function ComposerStory(props: {
variant: {
options: () => [
{ id: "default", label: "default" },
{ id: "balanced", label: "balanced" },
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
{ id: "high", label: "high" },
],
current: () => story.variant,
@@ -144,6 +150,17 @@ function ComposerStory(props: {
submit: {
stopping: () => !!props.stopping,
working: () => !!props.working,
queue: props.alternate
? {
count: () => 0,
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
cancelEdit() {},
editFirst: () => false,
}
: undefined,
onSubmit: () => {
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
const request = props.inspectRequest
@@ -290,6 +307,21 @@ export const NarrowLayout = {
),
}
export const ToolbarOverflow = {
args: { alternate: "queue" },
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
render: (args: { alternate: "none" | "queue" | "steer" }) => (
<div class="w-[min(420px,calc(100vw-80px))]">
<ComposerStory
longLabels
working
prompt={text("Keep all controls reachable when the toolbar overflows")}
alternate={args.alternate === "none" ? undefined : args.alternate}
/>
</div>
),
}
export const DemoFirstClassSkillIDs = {
name: "Demo: First-class skill IDs",
render: () => (
@@ -2,12 +2,36 @@
content: "\200B";
}
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
[data-slot="composer-controls"][data-overflow-start="true"] {
--mask-start: 16px;
}
[data-slot="composer-controls"][data-overflow-end="true"] {
--mask-end: 16px;
}
[data-slot="composer-controls"]:dir(rtl) {
--mask-direction: to left;
}
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
black var(--mask-start, 0px),
black calc(100% - var(--mask-end, 0px)),
transparent
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
+89 -55
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
const view = props.controller.view
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let controlsViewport!: HTMLDivElement
let controlsContent!: HTMLDivElement
const [overflow, setOverflow] = createStore({ start: false, end: false })
const updateOverflow = () => {
const offset = Math.abs(controlsViewport.scrollLeft)
setOverflow({
start: offset > 1,
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
})
}
onMount(() => {
const observer = new ResizeObserver(updateOverflow)
observer.observe(controlsViewport)
observer.observe(controlsContent)
updateOverflow()
onCleanup(() => observer.disconnect())
})
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -226,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<div class="flex h-11 items-center px-2">
<div
class="flex min-w-0 flex-1 items-center gap-1"
class="flex shrink-0 items-center"
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
@@ -245,64 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
onContext={props.controller.openContext}
onShell={props.controller.openShell}
/>
<Show when={view.agent} keyed>
{(control) => (
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
</div>
<div
ref={controlsViewport}
data-slot="composer-controls"
data-overflow-start={overflow.start}
data-overflow-end={overflow.end}
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
onScroll={updateOverflow}
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
>
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
<Show when={view.agent} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
)}
</Show>
</Show>
</div>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
<div data-slot="composer-actions" class="flex shrink-0 items-center">
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</div>
</div>
</form>
</div>
@@ -0,0 +1,6 @@
import { expect, test } from "bun:test"
import { popularProviders } from "./order"
test("lists OpenCode Go before Zen and other popular providers", () => {
expect(popularProviders.slice(0, 2)).toEqual(["opencode-go", "opencode"])
})
+1 -1
View File
@@ -1,6 +1,6 @@
export const popularProviders = [
"opencode",
"opencode-go",
"opencode",
"anthropic",
"github-copilot",
"openai",
@@ -110,7 +110,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
+1 -1
View File
@@ -13,7 +13,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
@@ -7,9 +7,6 @@ import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
baseUrl: "http://opencode.local",
@@ -79,26 +76,32 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads projects from the current endpoint", async () => {
test("loads each project's inventory through its own location using the real client", async () => {
const calls: string[] = []
const projects = {
list: async () => [
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
calls.push(directory)
return Response.json([
{ directory },
{ directory: `${directory}/clone` },
{ directory: `${directory}/copy`, strategy: "git" },
])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
@@ -109,24 +112,30 @@ describe("query keys", () => {
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["a", "b"])
expect(calls.toSorted()).toEqual(["/a", "/b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
return Response.json([{ directory: "/a/copy", strategy: "git" }])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
@@ -79,7 +79,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ projectID: project.id })
.list({ location: { directory: project.canonical } })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
@@ -3,11 +3,9 @@ import { useLocation } from "@solidjs/router"
import { ComposerEditor } from "@/composer/editor/editor"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerEditor } from "@/composer/editor/interaction"
import { useLanguage } from "@/runtime/i18n/language"
import type { PendingSession } from "@/shell/tabs/tabs"
export function PreparingComposer(props: { pending: PendingSession }) {
const language = useLanguage()
const location = useLocation()
let element: HTMLElement | undefined
const editor = createComposerEditor({
@@ -20,7 +18,6 @@ export function PreparingComposer(props: { pending: PendingSession }) {
},
view: {
draftOnly: true,
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
+1 -1
View File
@@ -64,7 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
provider: { all: providers.all(), default: providers.default(), connected: [] },
}}
>
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
<div data-component="session-preparing" data-workspace-session class="min-h-0 flex-1 overflow-y-auto">
<SessionIdentityHeader sessionID={props.sessionID} />
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
<SessionUserMessage
@@ -40,8 +40,7 @@ export function SessionWorkspaceMenu(props: {
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.list({ location: { directory: props.directory } })
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
@@ -58,6 +58,7 @@ export const SettingsProviders: Component<{
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
)
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
const popular = createMemo(() => {
@@ -65,7 +65,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ projectID: project.id })
.list({ location: { directory: project.canonical } })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
@@ -180,7 +180,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
+4 -4
View File
@@ -356,10 +356,10 @@ export function Titlebar(props: {
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
<Icon name="grid-plus" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("home.title")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
@@ -654,10 +654,10 @@ export function Titlebar(props: {
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
<Icon name="edit" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
+3 -7
View File
@@ -7,30 +7,27 @@ import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
@@ -66,10 +63,9 @@ describe("worktree creation", () => {
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
)
expect(
requests
+1 -3
View File
@@ -1,6 +1,5 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
@@ -11,11 +10,10 @@ export async function createWorktree(input: {
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
projectID: project.id,
location: { directory: input.directory },
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
// Populate the client cache before the destination session mounts.
await input.data.location.syncInfo({ directory: created.directory })
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
const update = yield* updater.run().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -83,11 +84,15 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
updater: {
monitor: (notify, signal) =>
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
+16
View File
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
yield* Updater.Service.pipe(
Effect.flatMap((updater) =>
Updater.pollUpdates({
check: updater.run().pipe(
Effect.flatMap((result) => {
if (!result) return Effect.void
if (result.type === "available") return server.updateAvailable(result.version)
return server.updated(result.version)
}),
),
}),
),
Effect.provide(Updater.layer),
Effect.forkScoped,
)
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"
+3 -3
View File
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "auto"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
return "notify"
return policy
}
export function parseReleaseVersion(input: string) {
+7 -2
View File
@@ -6,14 +6,14 @@ describe("updater", () => {
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
})
test("reports every available release", () => {
@@ -23,6 +23,11 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("automatically installs every available release when enabled", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("skips when updates are disabled", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
+62 -29
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export interface Interface {
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly run: () => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
export const pollUpdates = Effect.fnUntraced(function* (input: {
readonly check: Effect.Effect<unknown>
readonly initialDelay?: Duration.Input
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
const initialDelay = input.initialDelay ?? "90 seconds"
const check = Effect.gen(function* () {
const version = yield* input.inspect()
if (version !== undefined) yield* input.notify(version)
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
return yield* input.check.pipe(
Effect.repeat(Schedule.spaced(interval)),
Effect.delay(input.initialDelay ?? "1 minute"),
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify") return value
if (value === "auto") return "notify"
if (value === "disable" || value === "notify" || value === "auto") return value
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "notify"
if (input.autoupdate === true) return "auto"
}
const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
@@ -78,7 +78,7 @@ const make = Effect.gen(function* () {
return values.findLast((value) => value !== undefined) ?? "notify"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
try: (signal) =>
fetch(
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
{
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
@@ -168,17 +168,20 @@ const make = Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
return yield* exec(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
@@ -200,18 +203,19 @@ const make = Effect.gen(function* () {
return undefined
}
const current = yield* Ref.get(installedVersion)
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(current, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return undefined
}
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
return { policy, version }
})
const install = Effect.fnUntraced(function* (version: string) {
@@ -220,8 +224,10 @@ const make = Effect.gen(function* () {
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
const current = yield* Ref.get(installedVersion)
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installedVersion, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
return true
})
@@ -229,9 +235,36 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const check = Effect.fn("cli.updater.check")(function* () {
if (OPENCODE_LOCAL)
return {
type: "unavailable" as const,
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
}
const version = yield* latest()
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
const current = yield* Ref.get(installedVersion)
if (action(current, version, "auto") === "none") {
// An earlier check may have installed the update while this client is still running.
return action(OPENCODE_VERSION, current, "auto") === "none"
? undefined
: { type: "installed" as const, version: current }
}
return { type: "available" as const, version }
})
return Service.of({ monitor, apply, method, latest, upgrade })
const run = Effect.fn("cli.updater.run")(
function* () {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
)
return Service.of({ run, check, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+2 -1
View File
@@ -12,7 +12,8 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {
-40
View File
@@ -1,40 +0,0 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("89 seconds")
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("1 second")
expect(yield* Queue.take(updates)).toBe("2.0.0")
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
expect(yield* Queue.take(updates)).toBe("2.0.0")
}),
)
it.effect("does not notify when no update is available", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed(undefined),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
+24
View File
@@ -0,0 +1,24 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("polls after 1 minute and every 10 minutes after that", () =>
Effect.gen(function* () {
const checks = yield* Queue.unbounded<void>()
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("59 seconds")
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("1 second")
yield* Queue.take(checks)
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
yield* Queue.take(checks)
}),
)
+13 -9
View File
@@ -1931,33 +1931,37 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type WorktreeListInput = { readonly projectID: Project.ID }
export type WorktreeListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeListOutput = Worktree.List
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeCreateInput = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy?: Worktree.StrategyID | undefined
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory: AbsolutePath
readonly directory?: AbsolutePath | undefined
readonly name?: string | undefined
}
export type WorktreeCreateOutput = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeRemoveInput = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
export type WorktreeRemoveOutput = void
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
export type WorktreeRefreshInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeRefreshOutput = void
export type WorktreeRefreshOperation<E = never> = (
input: WorktreeRefreshInput,
input?: WorktreeRefreshInput,
) => Effect.Effect<WorktreeRefreshOutput, E>
export interface WorktreeApi<E = never> {
+12 -12
View File
@@ -1451,21 +1451,21 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
preserveEffect<WorktreeListOutput>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
query: { location: input?.["location"] },
payload: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1473,14 +1473,14 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
preserveEffect<WorktreeRemoveOutput>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
preserveEffect<WorktreeRefreshOutput>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
+1
View File
@@ -20,6 +20,7 @@ export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export type WorktreeApi = Client["worktree"]
export interface CatalogApi {
readonly provider: ProviderApi
+16 -12
View File
@@ -1968,28 +1968,30 @@ export function make(options: ClientOptions) {
),
},
worktree: {
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
path: `/api/worktree`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
path: `/api/worktree`,
query: { location: input?.["location"] },
body: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -2001,7 +2003,8 @@ export function make(options: ClientOptions) {
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
path: `/api/worktree`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -2009,11 +2012,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
path: `/api/worktree/refresh`,
query: { location: input?.["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+32 -17
View File
@@ -430,6 +430,8 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type ConfigWorktree = { directory: string }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -1892,7 +1894,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
update?: "disable" | "notify"
update?: "disable" | "notify" | "auto"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
@@ -1993,6 +1995,7 @@ export type ConfigEntry =
}
websearch?: false | { provider: "random" | (string & {}) }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
worktree?: ConfigWorktree
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
@@ -6077,45 +6080,51 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly strategy: {
readonly strategy: string
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy?: {
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy: string
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy: string
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["branch"]
readonly directory: {
readonly strategy: string
readonly directory?: {
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy: string
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["name"]
}
@@ -6123,14 +6132,20 @@ export type WorktreeCreateInput = {
export type WorktreeCreateOutput = WorktreeInfo
export type WorktreeRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type WorktreeRemoveOutput = void
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeRefreshInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshOutput = void
+40 -9
View File
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("worktree methods use the global project contract", async () => {
test("all worktree operations use location-based routes without a project parameter", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -350,27 +350,25 @@ test("worktree methods use the global project contract", async () => {
},
})
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
projectID: "proj_test",
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "proj_test",
directory: "/tmp/worktrees/api",
force: false,
})
await client.worktree.refresh({ projectID: "proj_test" })
await client.worktree.refresh()
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test"],
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
["GET", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree"],
["DELETE", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree/refresh"],
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
@@ -380,6 +378,39 @@ test("worktree methods use the global project contract", async () => {
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("worktree operations send the configuration location separately from their payload", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
return new Response(null, { status: 204 })
return Response.json({ directory: "/configured/task" })
},
})
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
directory: "/configured/task",
})
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[0]?.json()).toEqual({ name: "task" })
await client.worktree.remove({
location: { directory: "/repo/nested" },
directory: "/configured/task",
force: true,
})
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
{ directory: "/configured/task", strategy: "git" },
])
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
})
test("workspace.destroy returns the transition result", async () => {
let request: Request | undefined
const client = OpenCode.make({
+1 -1
View File
@@ -12,7 +12,7 @@ ${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalo
## Search
Use \`search\` to discover exact paths and signatures for additional tools:
Call \`search(...)\` to discover exact paths and signatures for additional tools:
- ${searchSignature}` : ""}
+1 -1
View File
@@ -61,7 +61,7 @@ export type Inventory = {
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
+2 -3
View File
@@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? input.update === "auto"
? "notify"
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
@@ -211,6 +209,7 @@ export function normalize(input: unknown): Result {
media: Info.fields.media,
tool_output: Info.fields.tool_output,
websearch: Info.fields.websearch,
worktree: Info.fields.worktree,
warming: Info.fields.warming,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
@@ -0,0 +1,35 @@
export * as ConfigWorktreePlugin from "./worktree.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import path from "path"
import { Config } from "../../config.js"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
import { Worktree } from "../../worktree.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.worktree",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
yield* worktrees.transform((editor) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.worktree) continue
const directory = entry.info.worktree.directory
editor.configure({
directory: AbsolutePath.make(
directory.startsWith("~/")
? path.join(global.home, directory.slice(2))
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
),
})
}
})
}),
})
+3 -1
View File
@@ -25,6 +25,7 @@ import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { WorktreeRefresh } from "./worktree/refresh.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
@@ -71,7 +72,8 @@ const nodes = [
PluginHooks.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
WorktreeRefresh.node,
Worktree.node,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
+40
View File
@@ -30,6 +30,7 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Worktree } from "../worktree.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
@@ -69,6 +70,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
const sessions = yield* Session.Service
const persistentPty = yield* PersistentPty.Service
const locations = yield* LocationServiceMap.Service
const worktrees = yield* Worktree.Service
const locationInfo = () =>
new Location.Info({
directory: location.directory,
@@ -88,6 +90,24 @@ export const make = Effect.fn("PluginHost.make")(function* (
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
const atWorktree = <A, E>(
ref: Location.Ref | undefined,
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
) => {
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
if (!ref || isCurrentLocation(ref)) return run(worktrees)
return Effect.gen(function* () {
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
const plugins = yield* Plugin.Service
const target = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* run(target)
}).pipe(Effect.provide(locations.get(ref)))
}
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
@@ -462,6 +482,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
})
}),
},
worktree: {
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
reload: worktrees.reload,
transform: (callback) =>
worktrees.transform((editor) =>
callback({
add: (definition) =>
editor.add({
id: Worktree.StrategyID.make(definition.id),
create: (input) => definition.create(input).pipe(Effect.flatMap(decodeWorktree)),
remove: (input) => definition.remove(input),
list: (directory) => definition.list(directory).pipe(Effect.flatMap(decodeWorktrees)),
}),
}),
),
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
create: (input) =>
@@ -510,6 +549,7 @@ export const requirements = LayerNode.group([
Tool.node,
Vcs.node,
WebSearch.node,
Worktree.node,
Generate.node,
Permission.node,
PluginHooks.node,
+5
View File
@@ -27,6 +27,8 @@ import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileMutation } from "../file-mutation.js"
@@ -135,6 +137,7 @@ const services = [
ToolOutput.Service,
Watcher.Service,
WellKnown.Service,
Worktree.Service,
] as const
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
@@ -183,6 +186,7 @@ export const requirements = LayerNode.group([
ToolOutput.node,
Watcher.node,
WellKnown.node,
Worktree.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
@@ -232,6 +236,7 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
+4 -2
View File
@@ -274,10 +274,12 @@ export const OpenAIPlugin = define({
return
}
const apiID = draft.modelID ?? draft.id
const match = apiID.match(/^gpt-(\d+\.\d+)/)
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
const major = Number(match?.[1])
const minor = Number(match?.[2] ?? 0)
if (
!codexAllowed.has(apiID) &&
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
) {
draft.enabled = false
return
+26 -15
View File
@@ -409,19 +409,17 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
const next = (delivery: Delivery) =>
db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const steer = yield* next("steer")
const steer = (yield* pendingSteers(db, sessionID))[0]
if (steer) return fromRow(steer)
if (promotable !== "input") return undefined
const queued = yield* next("queue")
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return queued ? fromRow(queued) : undefined
})
@@ -490,9 +488,10 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
* Promotes pending input into visible messages and returns the promoted count,
* or undefined when the runner must first handle a pending control.
* Steered compaction takes priority over pending prompts, without crossing a move.
* Only the "input" scope may fall through to one queued input.
*/
export const promote = Effect.fn("SessionInbox.promote")(function* (
db: DatabaseService,
@@ -506,6 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
const steers = yield* pendingSteers(db, sessionID)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
if (control === 0) return undefined
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
@@ -518,6 +518,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.get()
.pipe(Effect.orDie)
if (!queued) return 0
if (queued.type === "compaction" || queued.type === "move") return undefined
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* pendingSteers(db, sessionID)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
@@ -536,4 +537,14 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
.pipe(
Effect.orDie,
Effect.map((rows) => {
// A move changes the context's Location: never pull compaction across it.
// Within that boundary, compact before promoting even earlier steers so
// their text stays verbatim after the checkpoint, not inside its summary.
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
return rows
}),
)
+4 -1
View File
@@ -147,7 +147,7 @@ const layer = Layer.effect(
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
return yield* restore(
const ready = yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const promoted = yield* SessionInbox.promote(
@@ -156,6 +156,8 @@ const layer = Layer.effect(
sessionID,
entering && !continuing ? promotable : "steer",
)
// A control admitted during context preparation owns this boundary.
if (promoted === undefined) return undefined
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
@@ -164,6 +166,7 @@ const layer = Layer.effect(
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
if (ready) return ready
}
}),
),
+4 -2
View File
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify" || info.autoupdate === true
: info.autoupdate === "notify"
? "notify"
: undefined,
: info.autoupdate === true
? "auto"
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
+152 -148
View File
@@ -1,12 +1,13 @@
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Schema } from "effect"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Global } from "@opencode-ai/util/global"
import { ProjectSchema } from "./project/schema.js"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
@@ -20,8 +21,10 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { State } from "./state.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export { OperationError } from "@opencode-ai/schema/worktree"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
@@ -32,11 +35,6 @@ export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Worktree.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
@@ -46,16 +44,10 @@ export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
export const ListInput = Worktree.ListInput
export type ListInput = typeof ListInput.Type
export const List = Worktree.List
export type List = typeof List.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export const ListEntry = Worktree.ListEntry
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
@@ -78,9 +70,9 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedError<DuplicateStrategyError>()(
"Worktree.DuplicateStrategyError",
{ strategy: StrategyID },
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
"Worktree.UnsupportedLocationError",
{ directory: AbsolutePath },
) {}
export type Error =
@@ -89,6 +81,8 @@ export type Error =
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| UnsupportedLocationError
| Worktree.OperationError
| AppProcess.AppProcessError
| Git.WorktreeError
@@ -98,67 +92,78 @@ export interface Strategy {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}) => Effect.Effect<Info, unknown>
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
}
export const Event = Worktree.Event
interface StoredInput {
readonly projectID: ProjectSchema.ID
readonly directory: AbsolutePath
readonly strategy?: string
readonly replace?: boolean
}
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
export interface Editor {
readonly add: (strategy: Strategy) => void
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<List, Error>
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
readonly refresh: () => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const worktrees = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
const result = yield* worktrees.refresh({ projectID: location.project.id })
yield* Effect.logInfo("worktree refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const database = yield* Database.Service
const db = database.db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const location = yield* Location.Service
const global = yield* Global.Service
const projectID = location.project.id
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
const local = location.workspaceID
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
: Effect.void
const gitStrategy = yield* WorktreeGit.make
const state = State.create({
name: "worktree",
initial: () => ({
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
selected: gitStrategy.id,
}),
editor: (value): Editor => ({
configure: (settings) => {
value.directory = settings.directory
},
add: (strategy) => {
value.strategies.delete(strategy.id)
value.strategies.set(strategy.id, strategy)
value.selected = strategy.id
},
}),
})
const changed = Effect.fnUntraced(function* (update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
list: Effect.fnUntraced(function* () {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -168,7 +173,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -177,24 +182,21 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
primary: Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
return yield* db
.select({ directory: ProjectTable.worktree })
.from(ProjectTable)
.where(eq(ProjectTable.id, projectID))
.get()
.pipe(Effect.orDie)
}),
create: (input: StoredInput, tx?: Transaction) =>
(tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.values({
project_id: projectID,
directory: input.directory,
strategy: input.strategy,
})
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
set: {
strategy: input.strategy ?? null,
},
// Discovery may claim an unowned row, but never replace another strategy's ownership.
setWhere: input.replace ? undefined : input.strategy ? isNull(WorktreeTable.strategy) : sql`false`,
})
.returning({ directory: WorktreeTable.directory })
.get()
@@ -202,7 +204,7 @@ const layer = Layer.effect(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) =>
remove: (directory: AbsolutePath, tx?: Transaction) =>
(tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
@@ -214,62 +216,57 @@ const layer = Layer.effect(
),
}
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
const gitStrategy = yield* WorktreeGit.make
yield* register(gitStrategy).pipe(Effect.orDie)
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined, projectID: ProjectSchema.ID) {
const sourceDirectory = input ?? (yield* ops.primary(projectID))?.directory
if (!sourceDirectory) return yield* new SourceDirectoryNotFoundError({ projectID })
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
const sourceDirectory = input ?? location.project.directory
const resolved = yield* canonical(fs, sourceDirectory)
if ((yield* ops.find(projectID, resolved)) === undefined)
if ((yield* ops.find(resolved)) === undefined)
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
return resolved
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
const found = strategies.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.from, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
yield* local
const current = state.get()
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
const directory = input.directory ?? current.directory
const sourceDirectory = yield* source(input.from)
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
const created = yield* selected
.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
return yield* new InvalidDirectoryError({ directory: result.directory })
yield* changed(
input.projectID,
yield* ops.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
strategy: selected.id,
replace: true,
}),
)
const project = yield* db
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.where(eq(ProjectTable.id, projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
@@ -294,70 +291,71 @@ const layer = Layer.effect(
})
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
yield* local
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
const stored = yield* ops.find(worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: worktreeDirectory,
force: input.force,
})
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
yield* strategy
.remove({
directory: worktreeDirectory,
force: input.force,
})
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
yield* changed(yield* ops.remove(worktreeDirectory))
})
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
const stored = yield* ops.list(input.projectID)
const refresh = Effect.fn("Worktree.refresh")(function* () {
yield* local
const stored = yield* ops.list()
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(Array.from(registry.values()), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "worktree" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const strategies = Array.from(state.get().strategies.values()).toReversed()
const discovered = new Map<AbsolutePath, StoredInput>()
// A location's plugin instances only discover its own checkout, not sibling clones.
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
for (const strategy of strategies) {
const entries = yield* strategy.list(location.project.directory).pipe(
Effect.mapError((error) => operationError(strategy.id, "list", error)),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
)
for (const entry of entries) {
const directory = yield* canonical(fs, entry.directory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
)
if (!directory || discovered.has(directory)) continue
discovered.set(directory, {
directory,
strategy: entry.type === "worktree" ? strategy.id : undefined,
})
}
}
}
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const changes = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.filter(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
},
tx,
),
).pipe(Effect.map((items) => items.map((item) => item.directory))),
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
Effect.map((items) => items.map((item) => item.directory)),
),
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
}),
)
.pipe(Effect.orDie)
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
register,
list: ops.list,
transform: state.transform,
reload: state.reload,
list: Effect.fn("Worktree.list")(function* () {
yield* refresh()
return yield* ops.list()
}),
create,
remove,
refresh,
@@ -365,14 +363,20 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
})
export const refreshNode = makeLocationNode({
name: "worktree-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
function operationError(strategy: StrategyID, operation: string, error: unknown) {
if (
error instanceof Git.WorktreeError ||
error instanceof DirectoryUnavailableError ||
error instanceof Worktree.OperationError
)
return error
return new Worktree.OperationError({
message: `Worktree strategy ${strategy} failed to ${operation}: ${error instanceof globalThis.Error ? error.message : String(error)}`,
})
}
+28
View File
@@ -0,0 +1,28 @@
export * as WorktreeRefresh from "./refresh.js"
import { Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Location } from "../location.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Worktree } from "../worktree.js"
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const worktrees = yield* Worktree.Service
if (location.workspaceID) return
yield* plugins.awaitActivation.pipe(
Effect.andThen(worktrees.refresh()),
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
)
}),
)
export const node = makeLocationNode({
name: "worktree-refresh",
layer,
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
})
@@ -131,6 +131,7 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("## Available tools")
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("Call `search(...)` to discover exact paths and signatures for additional tools:")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
+3 -3
View File
@@ -666,14 +666,14 @@ describe("Config", () => {
test("migrates the v1 update policy", () => {
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
})
test("normalizes the previous native auto update policy", () => {
test("normalizes the native auto update policy", () => {
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
type: "normalized",
encoded: { update: "notify" },
encoded: { update: "auto" },
diagnostics: [],
})
})
+2
View File
@@ -32,6 +32,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Worktree } from "@opencode-ai/core/worktree"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
@@ -94,6 +95,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Vcs.node,
Watcher.node,
WebSearch.node,
Worktree.node,
]),
[
Location.node.replace(tempLocationLayer),
+8
View File
@@ -145,6 +145,14 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused vcs.transform"),
reload: () => Effect.die("unused vcs.reload"),
},
worktree: overrides.worktree ?? {
list: () => Effect.die("unused worktree.list"),
create: () => Effect.die("unused worktree.create"),
remove: () => Effect.die("unused worktree.remove"),
refresh: () => Effect.die("unused worktree.refresh"),
transform: () => Effect.die("unused worktree.transform"),
reload: () => Effect.die("unused worktree.reload"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),
@@ -115,6 +115,15 @@ describe("OpenAIPlugin", () => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
@@ -161,6 +170,11 @@ describe("OpenAIPlugin", () => {
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false)
}),
)
@@ -0,0 +1,52 @@
import { expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { Plugin } from "@opencode-ai/plugin"
import type { WorktreeDefinition } from "@opencode-ai/plugin/effect/worktree"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { State } from "@opencode-ai/core/state"
import { it } from "../lib/effect"
import { host } from "./host"
it.live("Promise worktree callbacks receive interruption through their AbortSignal", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<AbortSignal>()
const state = State.create({
initial: () => new Map<string, WorktreeDefinition>(),
editor: (value) => ({
add: (definition: WorktreeDefinition) => {
value.set(definition.id, definition)
},
}),
})
const context = host()
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "cancel-worktree",
async setup(ctx) {
await ctx.worktree.transform((editor) =>
editor.add({
id: "cancel",
create: (_input, { signal }) =>
new Promise<never>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
Effect.runSync(Deferred.succeed(started, signal))
}),
remove: async () => {},
list: async () => [],
}),
)
},
}),
)
yield* plugin.effect(host({ worktree: { ...context.worktree, transform: state.transform, reload: state.reload } }))
const strategy = state.get().get("cancel")
if (!strategy) return yield* Effect.die("Strategy was not registered")
const fiber = yield* strategy.create({ sourceDirectory: "/source", directory: "/target" }).pipe(Effect.forkScoped)
const signal = yield* Deferred.await(started)
expect(signal.aborted).toBe(false)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(signal.aborted).toBe(true)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
+179
View File
@@ -1925,6 +1925,185 @@ describe("SessionRunnerLLM", () => {
).toEqual(["Replacement context"])
})
for (const order of ["before", "between", "after"] as const) {
scenario(`prioritizes manual compaction admitted ${order} two steers at the safe boundary`, function* (s) {
s.currentModel = recoveryModel
yield* s.llm.push(
TestLLM.text("Active complete", "active"),
TestLLM.text("## Objective\n- Active work checkpoint", "summary"),
TestLLM.text("Steers complete", "steers"),
)
yield* s.admit("Active work")
const active = yield* s.resumePaused
const compactID = SessionMessage.ID.create()
if (order === "before") yield* s.session.compact({ sessionID, id: compactID })
const first = yield* s.admit("STEER_A")
if (order === "between") yield* s.session.compact({ sessionID, id: compactID })
const second = yield* s.admit("STEER_B")
if (order === "after") yield* s.session.compact({ sessionID, id: compactID })
expect((yield* s.session.compact({ sessionID })).id).toBe(compactID)
expect(s.requests).toHaveLength(1)
expect(yield* s.inbox).toHaveLength(3)
expect((yield* s.messages).some((message) => message.type === "compaction")).toBe(false)
yield* active.finish
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])).not.toContain("STEER_A")
expect(userTexts(s.requests[1])).not.toContain("STEER_B")
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
expect((yield* s.messages).filter((message) => message.id === compactID)).toMatchObject([
{ type: "compaction", status: "completed" },
])
expect((yield* s.context).filter((message) => message.type === "user").map((message) => message.id)).toEqual([
first.id,
second.id,
])
// An advisory drain must not redeliver either steer or rerun compaction.
const runner = yield* SessionRunner.Service
yield* runner.drain({ sessionID, force: false })
expect(s.requests).toHaveLength(3)
})
}
scenario("waits for active tools before prioritizing compaction over pending steers", function* (s) {
yield* s.llm.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
TestLLM.text("## Objective\n- Tool work checkpoint", "summary"),
TestLLM.text("Steers complete", "steers"),
)
yield* s.admit("Active work")
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.admit("STEER_A")
yield* s.admit("STEER_B")
const compact = yield* s.session.compact({ sessionID })
expect(s.requests).toHaveLength(1)
expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(s.requests[1].messages.some((message) => message.role === "tool")).toBe(true)
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
})
scenario("rechecks compaction admitted during boundary context preparation", function* (s) {
yield* s.runPrompt("Earlier work")
yield* s.admit("STEER_A")
yield* s.admit("STEER_B")
const preparing = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
s.systemLoadHook = Deferred.succeed(preparing, undefined).pipe(Effect.andThen(Deferred.await(release)))
yield* s.llm.push(
TestLLM.text("## Objective\n- Earlier work checkpoint", "summary"),
TestLLM.text("Steers complete", "steers"),
)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Deferred.await(preparing)
yield* s.session.compact({ sessionID })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(run)
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
})
for (const outcome of ["cancelled", "failed"] as const) {
scenario(`preserves both earlier steers when prioritized compaction is ${outcome}`, function* (s) {
yield* s.llm.push(TestLLM.text("Active complete", "active"))
yield* s.admit("Active work")
const active = yield* s.resumePaused
const first = yield* s.admit("STEER_A")
const second = yield* s.admit("STEER_B")
const compact = yield* s.session.compact({ sessionID })
if (outcome === "cancelled") yield* s.session.cancelInbox({ sessionID, inboxID: compact.id })
if (outcome === "failed") yield* s.llm.push([LLMEvent.providerError({ message: "summary unavailable" })])
yield* s.llm.push(TestLLM.text("Steers complete", "steers"))
yield* active.finish
expect(s.requests).toHaveLength(outcome === "cancelled" ? 2 : 3)
if (outcome === "failed") {
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({
status: "failed",
error: { type: "provider.error", message: "summary unavailable" },
})
}
if (outcome === "cancelled") expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
expect(userTexts(s.requests[s.requests.length - 1]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(
(yield* s.context)
.filter((message) => message.id === first.id || message.id === second.id)
.map((message) => message.id),
).toEqual([first.id, second.id])
expect(yield* s.inbox).toEqual([])
})
}
scenario("keeps steers durable across interrupted priority compaction and replay", function* (s) {
yield* s.runPrompt("Earlier work")
const first = yield* s.admit("STEER_A")
const second = yield* s.admit("STEER_B")
yield* s.llm.push(TestLLM.text("## Objective\n- Interrupted checkpoint", "summary"))
const summary = yield* s.llm.gate
const compact = yield* s.session.compact({ sessionID })
yield* summary.started
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
yield* s.session.interrupt(sessionID)
yield* s.session.wait(sessionID)
yield* summary.release
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({ status: "failed" })
yield* replaySessionProjection(sessionID)
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
yield* s.llm.push(TestLLM.text("Recovered steers", "steers"))
yield* s.resume
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
expect(yield* s.inbox).toEqual([])
})
scenario("does not pull compaction across an earlier move", function* (s) {
yield* s.admit("STEER_A")
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* s.admit("STEER_B")
yield* s.sessionInbox.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "steer" })
yield* s.llm.push(
TestLLM.text("First steer complete", "first"),
TestLLM.text("## Objective\n- Source work checkpoint", "summary"),
TestLLM.text("Second steer complete", "second"),
)
yield* s.resume
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[0])).toEqual(["STEER_A"])
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
expect(userTexts(s.requests[2]).at(-1)).toBe("STEER_B")
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === "session.moved.1" || type === "session.compaction.started.1",
),
).toEqual(["session.moved.1", "session.compaction.started.1"])
})
scenario("runs steers before queued compaction and later queued input", function* (s) {
s.currentModel = recoveryModel
yield* s.llm.push(
-1
View File
@@ -114,7 +114,6 @@ test("Core reuses the canonical shared schemas", async () => {
[coreWorktree.CreateInput, Worktree.CreateInput],
[coreWorktree.RemoveInput, Worktree.RemoveInput],
[coreWorktree.Info, Worktree.Info],
[coreWorktree.ListInput, Worktree.ListInput],
[coreWorktree.List, Worktree.List],
[coreWorktree.Event, Worktree.Event],
[corePty.Info, Pty.Info],
+1 -1
View File
@@ -24,7 +24,7 @@ test("execute describes invariant Code Mode behavior", () => {
[
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
+352 -39
View File
@@ -3,7 +3,7 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { and, eq, isNull } from "drizzle-orm"
import { Effect, Fiber, Stream } from "effect"
import { Context, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -15,14 +15,64 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { WorktreeGit } from "@opencode-ai/core/worktree/git"
import { Location } from "@opencode-ai/core/location"
import { Global } from "@opencode-ai/util/global"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "@opencode-ai/core/config"
import { ConfigWorktreePlugin } from "@opencode-ai/core/config/plugin/worktree"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Workspace } from "@opencode-ai/schema/workspace"
import { host } from "./plugin/host"
import { initRepo } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
const projectIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
class Fixture extends Context.Service<Fixture, Effect.Success<ReturnType<typeof makeFixture>>>()("WorktreeFixture") {}
const infrastructure = AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node]))
const it = testEffect(
Layer.unwrap(
Effect.gen(function* () {
const input = yield* makeFixture()
const database = yield* Database.Service
const bus = yield* Bus.Service
return Layer.mergeAll(
Layer.succeed(Fixture, input),
Config.testLayer(),
worktreeLayer(input.sourceDirectory, input.projectID, database, bus, input.root.path),
)
}),
).pipe(Layer.provideMerge(infrastructure)),
)
const projectIt = it
function worktreeLayer(
directory: AbsolutePath,
projectID: Project.ID,
database: Database.Interface,
bus: Bus.Interface,
data: string,
workspaceID?: Workspace.ID,
) {
return AppNodeBuilder.build(LayerNode.group([Worktree.node, Git.node, FSUtil.node, Location.node, Global.node]), [
Database.node.replace(Layer.succeed(Database.Service, database)),
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Global.node.replace(Global.layerWith({ data })),
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of({
directory,
workspaceID,
project: { id: projectID, directory, canonical: directory },
}),
),
),
]).pipe(Layer.fresh)
}
function abs(input: string) {
return AbsolutePath.make(input)
@@ -30,7 +80,11 @@ function abs(input: string) {
const gitWorktree = Worktree.StrategyID.make("git")
function setup() {
const setup = Effect.fnUntraced(function* () {
return yield* Fixture
})
function makeFixture() {
return Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -107,7 +161,6 @@ describe("Worktree", () => {
const unavailable = Worktree.StrategyID.make("acme/missing")
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: unavailable,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-strategy`),
@@ -131,7 +184,6 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-source`),
@@ -159,7 +211,6 @@ describe("Worktree", () => {
yield* Effect.yieldNow
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
@@ -173,13 +224,39 @@ describe("Worktree", () => {
)
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ directory: created.directory, force: false })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const global = yield* Global.Service
const parent = path.join(global.data, "worktree", "worktr")
const created = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
const duplicate = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
expect(created.directory).toBe(abs(path.join(parent, "task")))
expect(duplicate.directory).toBe(abs(path.join(parent, "task-2")))
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, ".git")).exists())).toBe(true)
yield* worktree.remove({ directory: created.directory, force: false })
yield* worktree.remove({ directory: duplicate.directory, force: false })
}),
)
it.live("runs the project setup script with worktree paths", () =>
Effect.gen(function* () {
const input = yield* setup()
@@ -199,7 +276,6 @@ describe("Worktree", () => {
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
@@ -210,7 +286,7 @@ describe("Worktree", () => {
created.directory,
created.directory,
])
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
yield* worktree.remove({ directory: created.directory, force: true })
}),
)
@@ -233,9 +309,12 @@ describe("Worktree", () => {
.quiet()
})
const projects = yield* Project.Service
const worktrees = yield* Worktree.Service
const initial = yield* projects.resolve(main)
const selected = yield* projects.resolve(clone)
const database = yield* Database.Service
const bus = yield* Bus.Service
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
const worktrees = Context.get(context, Worktree.Service)
yield* projects.update({
projectID: initial.id,
commands: {
@@ -245,7 +324,6 @@ describe("Worktree", () => {
})
const created = yield* worktrees.create({
projectID: selected.id,
strategy: gitWorktree,
from: selected.canonical,
directory: abs(path.join(root.path, "worktrees")),
@@ -276,7 +354,6 @@ describe("Worktree", () => {
})
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
branch: "feature-base",
directory: parent,
@@ -300,7 +377,6 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
branch: "--no-checkout",
directory: parent,
@@ -321,7 +397,6 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: abs(path.join(temp, "does-not-exist")),
directory: abs(`${input.root.path}-missing-directory`),
@@ -347,7 +422,6 @@ describe("Worktree", () => {
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: sourceParent,
@@ -360,7 +434,6 @@ describe("Worktree", () => {
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: source.directory,
directory: targetParent,
@@ -368,8 +441,8 @@ describe("Worktree", () => {
})
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
yield* worktree.remove({ directory: created.directory, force: false })
yield* worktree.remove({ directory: source.directory, force: false })
}),
)
@@ -381,7 +454,6 @@ describe("Worktree", () => {
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
@@ -389,9 +461,7 @@ describe("Worktree", () => {
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const error = yield* worktree
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)
const error = yield* worktree.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Git.WorktreeError)
if (error instanceof Git.WorktreeError) {
@@ -401,7 +471,7 @@ describe("Worktree", () => {
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
yield* worktree.remove({ directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)
@@ -419,9 +489,7 @@ describe("Worktree", () => {
.run()
.pipe(Effect.orDie)
const error = yield* worktree
.remove({ projectID: input.projectID, directory: unavailable, force: false })
.pipe(Effect.flip)
const error = yield* worktree.remove({ directory: unavailable, force: false }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
@@ -440,7 +508,6 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
@@ -455,7 +522,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
).toBe(true)
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ directory: created.directory, force: false })
}),
)
@@ -476,7 +543,6 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
@@ -492,7 +558,6 @@ describe("Worktree", () => {
it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const bus = yield* Bus.Service
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
@@ -502,7 +567,7 @@ describe("Worktree", () => {
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* worktree.refresh({ projectID: input.projectID })
yield* worktree.refresh()
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
@@ -541,7 +606,7 @@ describe("Worktree", () => {
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
const existing = abs(yield* Effect.promise(() => fs.realpath(unchanged)))
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* worktree.refresh()).toEqual({ updated: [discovered], removed: [] })
expect(yield* stored(input.projectID)).toEqual(
[
@@ -554,7 +619,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({
expect(yield* worktree.refresh()).toEqual({
updated: [],
removed: [discovered, existing].toSorted(),
})
@@ -575,7 +640,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* worktree.refresh({ projectID: input.projectID })
yield* worktree.refresh()
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
@@ -594,7 +659,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const worktree = yield* Worktree.Service
yield* worktree.refresh({ projectID: input.projectID })
yield* worktree.refresh()
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
@@ -602,9 +667,15 @@ describe("Worktree", () => {
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* input.db
.delete(WorktreeTable)
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
expect(yield* worktree.refresh()).toEqual({
updated: [],
removed: [],
})
@@ -622,9 +693,251 @@ describe("Worktree", () => {
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [missing] })
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
}),
)
it.live("defaults to Git and configured directory without depending on Config", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const parent = abs(path.join(input.root.path, "configured"))
const registration = yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
const created = yield* worktrees.create({ name: "configured" })
expect(created.directory).toBe(abs(path.join(parent, "configured")))
expect(yield* worktrees.list()).toContainEqual({
directory: created.directory,
strategy: "git",
})
yield* registration.dispose
const fallback = yield* worktrees.create({ name: "default" })
expect(fallback.directory).toBe(
abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "default")),
)
}),
)
it.live("selects the last active registration and restores earlier strategies on disposal", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
const parent = abs(path.join(input.root.path, "strategies"))
const first = yield* worktrees.transform((editor) =>
editor.add({ ...git, id: Worktree.StrategyID.make("first") }),
)
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
const second = yield* worktrees
.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("second") }))
.pipe(Effect.provideService(Scope.Scope, scope))
yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
const created = yield* worktrees.create({ name: "second" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" })
yield* Scope.close(scope, Exit.void)
yield* second.dispose
const earlier = yield* worktrees.create({ name: "first" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: earlier.directory, strategy: "first" })
yield* first.dispose
const fallback = yield* worktrees.create({ name: "git" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: fallback.directory, strategy: "git" })
const error = yield* worktrees.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
yield* worktrees.refresh()
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" })
}),
)
it.live("does not fall back to Git when a registered strategy fails", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) =>
editor.add({
...git,
id: Worktree.StrategyID.make("broken"),
create: () => Effect.fail(new Error("backend failed")),
}),
)
const parent = abs(path.join(input.root.path, "failures"))
const error = yield* worktrees.create({ directory: parent, name: "failure" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.OperationError)
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
const explicit = yield* worktrees.create({
directory: parent,
name: "explicit",
strategy: gitWorktree,
})
expect(yield* stored(input.projectID)).toContainEqual({ directory: explicit.directory, strategy: "git" })
}),
)
it.live("rejects a source override belonging to another project", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const projects = yield* Project.Service
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
yield* Effect.promise(() => initRepo(other.path))
const resolved = yield* projects.resolve(abs(other.path))
expect(resolved.id).not.toBe(input.projectID)
const error = yield* worktrees.create({ from: abs(other.path), name: "nope" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID)
}),
)
it.live("cannot remove a worktree from another project through the current location", () =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
const projects = yield* Project.Service
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
yield* Effect.promise(() => initRepo(other.path))
const linked = abs(path.join(other.path, "linked"))
yield* Effect.promise(() => $`git worktree add --detach ${linked} HEAD`.cwd(other.path).quiet())
const resolved = yield* projects.resolve(linked)
const error = yield* worktrees.remove({ directory: linked, force: true }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.InvalidDirectoryError)
expect(yield* stored(resolved.id)).toContainEqual({ directory: linked, strategy: "git" })
expect(yield* Effect.promise(() => fs.stat(linked).then((item) => item.isDirectory()))).toBe(true)
}),
)
it.live("rejects workspace-qualified locations before running worktree operations", () =>
Effect.gen(function* () {
const input = yield* setup()
const database = yield* Database.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const context = yield* Layer.build(
worktreeLayer(
input.sourceDirectory,
input.projectID,
database,
bus,
input.root.path,
Workspace.ID.make("wrk_remote"),
),
)
const worktrees = Context.get(context, Worktree.Service)
const directory = abs(path.join(input.root.path, "not-created"))
const errors = yield* Effect.all([
worktrees.list().pipe(Effect.flip),
worktrees.create({ directory, name: "task" }).pipe(Effect.flip),
worktrees.remove({ directory: input.sourceDirectory, force: true }).pipe(Effect.flip),
worktrees.refresh().pipe(Effect.flip),
])
for (const error of errors) expect(error).toBeInstanceOf(Worktree.UnsupportedLocationError)
expect(yield* fs.existsSafe(directory)).toBe(false)
expect(yield* fs.isDir(input.sourceDirectory)).toBe(true)
}),
)
it.live("list invokes the location's strategies before returning inventory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
const directory = abs(path.join(input.root.path, "discovered"))
yield* Effect.promise(() => fs.mkdir(directory))
const sources: AbsolutePath[] = []
yield* worktrees.transform((editor) =>
editor.add({
...git,
id: Worktree.StrategyID.make("discovered-copy"),
list: (sourceDirectory) =>
Effect.sync(() => {
sources.push(sourceDirectory)
return [{ directory, type: "worktree" as const }]
}),
}),
)
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([input.sourceDirectory])
expect(yield* stored(input.projectID)).toContainEqual({ directory, strategy: "discovered-copy" })
yield* Effect.promise(() => fs.rmdir(directory))
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([input.sourceDirectory, input.sourceDirectory])
}),
)
it.live("list surfaces strategy discovery failures", () =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) =>
editor.add({
...git,
id: Worktree.StrategyID.make("broken-discovery"),
list: () => Effect.fail(new Error("Cannot enumerate worktrees")),
}),
)
const error = yield* worktrees.list().pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.OperationError)
if (error instanceof Worktree.OperationError) expect(error.message).toContain("Cannot enumerate worktrees")
}),
)
it.live("applies directory config through the adapter and restores defaults after config removal", () =>
Effect.gen(function* () {
const input = yield* setup()
const config = yield* Config.Test
const worktrees = yield* Worktree.Service
const bus = yield* Bus.Service
const reloaded = yield* Queue.unbounded<void>()
const documents = [
new Document({
type: "document",
path: abs(path.join(input.root.path, "opencode.json")),
info: new Info({ worktree: { directory: "outer" } }),
}),
new Document({
type: "document",
path: abs(path.join(input.root.path, "nested/opencode.json")),
info: new Info({ worktree: { directory: "copies" } }),
}),
]
yield* config.setEntries(documents)
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("custom") }))
yield* ConfigWorktreePlugin.Plugin.effect(
host({ event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)) } }),
).pipe(
Effect.provideService(Worktree.Service, {
...worktrees,
reload: () => worktrees.reload().pipe(Effect.tap(() => Queue.offer(reloaded, undefined))),
}),
)
const first = yield* worktrees.create({ name: "one" })
expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one")))
expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" })
yield* config.setEntries(documents.slice(0, 1))
yield* bus.publish(Event.Updated, {})
yield* Queue.take(reloaded)
const second = yield* worktrees.create({ name: "two" })
expect(second.directory).toBe(abs(path.join(input.root.path, "outer/two")))
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
yield* Queue.take(reloaded)
const third = yield* worktrees.create({ name: "three" })
expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three")))
}),
)
it.effect("normalization retains worktree directory and rejects invalid configuration", () =>
Effect.sync(() => {
expect(ConfigNormalize.normalize({ worktree: { directory: "./copies" } })).toMatchObject({
type: "normalized",
encoded: { worktree: { directory: "./copies" } },
diagnostics: [],
})
for (const worktree of [{ directory: " " }, { directory: 12 }, {}]) {
const result = ConfigNormalize.normalize({ worktree })
expect(result.diagnostics.length).toBeGreaterThan(0)
if (result.type === "normalized") expect(result.encoded).not.toHaveProperty("worktree")
}
}),
)
})
+1
View File
@@ -16,3 +16,4 @@ export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { Worktree } from "@opencode-ai/schema/worktree"
+2
View File
@@ -20,6 +20,7 @@ import type { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { VcsDomain } from "./vcs.js"
import type { WebSearchDomain } from "./websearch.js"
import type { WorktreeDomain } from "./worktree.js"
export interface Context {
readonly app: App
@@ -47,6 +48,7 @@ export interface Context {
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
readonly worktree: WorktreeDomain
}
export interface Plugin<R = Scope.Scope> {
+21
View File
@@ -0,0 +1,21 @@
import type { WorktreeApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js"
import type { Transform } from "./registration.js"
export interface WorktreeDefinition {
readonly id: string
readonly create: (input: WorktreeCreateInput) => Effect.Effect<WorktreeResult, unknown>
readonly remove: (input: WorktreeRemoveInput) => Effect.Effect<void, unknown>
readonly list: (sourceDirectory: string) => Effect.Effect<readonly WorktreeEntry[], unknown>
}
export interface WorktreeEditor {
/** Registers an implementation and selects it as the default. Later active registrations win. */
add(definition: WorktreeDefinition): void
}
export interface WorktreeDomain extends WorktreeApi<unknown> {
readonly transform: Transform<WorktreeEditor>
readonly reload: () => Effect.Effect<void>
}
+22
View File
@@ -234,6 +234,7 @@ export function fromPromise(plugin: Plugin) {
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const WorktreeEndpoints = ClientApi.groups["server.worktree"].endpoints
const context = yield* Effect.context<Scope.Scope>()
const streams = yield* makeStreams()
@@ -538,6 +539,27 @@ export function fromPromise(plugin: Plugin) {
}),
),
},
worktree: {
list: adaptApiMethod(WorktreeEndpoints["worktree.list"], host.worktree.list),
create: adaptApiMethod(WorktreeEndpoints["worktree.create"], host.worktree.create),
remove: adaptApiMethod(WorktreeEndpoints["worktree.remove"], host.worktree.remove),
refresh: adaptApiMethod(WorktreeEndpoints["worktree.refresh"], host.worktree.refresh),
reload: () => run(host.worktree.reload()),
transform: (callback) =>
register(
host.worktree.transform((editor) =>
callback({
add: (definition) =>
editor.add({
id: definition.id,
create: (input) => attempt((signal) => definition.create(input, { signal })),
remove: (input) => attempt((signal) => definition.remove(input, { signal })),
list: (directory) => attempt((signal) => definition.list(directory, { signal })),
}),
}),
),
),
},
session: {
hook: (name, callback, options) =>
register(
+1
View File
@@ -17,3 +17,4 @@ export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { Worktree } from "@opencode-ai/schema/worktree"
+2
View File
@@ -20,6 +20,7 @@ import type { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { VcsDomain } from "./vcs.js"
import type { WebSearchDomain } from "./websearch.js"
import type { WorktreeDomain } from "./worktree.js"
export interface Context {
readonly app: App
@@ -47,6 +48,7 @@ export interface Context {
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
readonly worktree: WorktreeDomain
}
export type Cleanup = () => Promise<void> | void
+23
View File
@@ -0,0 +1,23 @@
import type { WorktreeApi } from "@opencode-ai/client/promise/api"
import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js"
import type { Transform } from "./registration.js"
export interface WorktreeDefinition {
readonly id: string
readonly create: (input: WorktreeCreateInput, context: { readonly signal: AbortSignal }) => Promise<WorktreeResult>
readonly remove: (input: WorktreeRemoveInput, context: { readonly signal: AbortSignal }) => Promise<void>
readonly list: (
sourceDirectory: string,
context: { readonly signal: AbortSignal },
) => Promise<readonly WorktreeEntry[]>
}
export interface WorktreeEditor {
/** Registers an implementation and selects it as the default. Later active registrations win. */
add(definition: WorktreeDefinition): void
}
export interface WorktreeDomain extends WorktreeApi {
readonly transform: Transform<WorktreeEditor>
readonly reload: () => Promise<void>
}
+24
View File
@@ -162,6 +162,21 @@ type PromptFooterInput = {
readonly showDetails: boolean
}
export type PanelPresentation = "panel" | "fullscreen"
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
export interface PanelInput {
/** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */
readonly name: string
readonly sessionID: string
readonly width: number
readonly presentation: PanelPresentation
readonly focused: boolean
readonly focus: () => void
readonly close: () => void
readonly toggleFullscreen: () => void
}
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
* may render around, inside, or take over. Paths are absolute and
@@ -180,6 +195,7 @@ export interface SlotMap {
readonly "prompt.footer.status": PromptFooterInput
readonly "prompt.footer.file": PromptFooterInput
readonly "session.composer.top": { readonly sessionID: string }
readonly "session.panel": PanelInput
readonly "sidebar.content": { readonly sessionID: string }
readonly "sidebar.footer": { readonly sessionID: string }
}
@@ -450,6 +466,14 @@ export interface UI {
navigate(destination: Destination): void
current(): Route
}
readonly panel: {
/** Opens the session.panel slot in the current session. */
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
close(): void
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
current(): { readonly name: string; readonly sessionID: string } | undefined
}
readonly tabs: {
/** Returns whether session tabs are enabled for this TUI. */
enabled(): boolean
+19
View File
@@ -0,0 +1,19 @@
export interface WorktreeCreateInput {
readonly sourceDirectory: string
readonly directory: string
/** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */
readonly branch?: string
}
export interface WorktreeRemoveInput {
readonly directory: string
readonly force: boolean
}
export interface WorktreeResult {
readonly directory: string
}
export interface WorktreeEntry extends WorktreeResult {
readonly type: "root" | "worktree"
}
+219 -57
View File
@@ -11997,18 +11997,50 @@
"summary": "List references"
}
},
"/api/worktree/{projectID}": {
"/api/worktree": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
"parameters": [
{
"name": "projectID",
"in": "path",
"name": "location",
"in": "query",
"schema": {
"type": "string"
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": true
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -12024,11 +12056,18 @@
}
},
"400": {
"description": "InvalidRequestError",
"description": "WorktreeError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
@@ -12044,7 +12083,7 @@
}
}
},
"description": "List known local worktrees for a project.",
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"summary": "List worktrees"
},
"post": {
@@ -12052,12 +12091,44 @@
"operationId": "v2.worktree.create",
"parameters": [
{
"name": "projectID",
"in": "path",
"name": "location",
"in": "query",
"schema": {
"type": "string"
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": true
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -12100,32 +12171,13 @@
}
}
},
"description": "Create a worktree for a project and run its configured setup script.",
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["strategy", "directory"],
"additionalProperties": false
"$ref": "#/components/schemas/Worktree.CreateInput"
}
}
},
@@ -12137,12 +12189,44 @@
"operationId": "v2.worktree.remove",
"parameters": [
{
"name": "projectID",
"in": "path",
"name": "location",
"in": "query",
"schema": {
"type": "string"
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": true
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -12178,23 +12262,13 @@
}
}
},
"description": "Remove a managed worktree from a project.",
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"summary": "Remove worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
"$ref": "#/components/schemas/Worktree.RemoveInput"
}
}
},
@@ -12202,18 +12276,50 @@
}
}
},
"/api/worktree/{projectID}/refresh": {
"/api/worktree/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",
"parameters": [
{
"name": "projectID",
"in": "path",
"name": "location",
"in": "query",
"schema": {
"type": "string"
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": true
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -12249,7 +12355,7 @@
}
}
},
"description": "Reconcile stored worktrees with the project repositories.",
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"summary": "Refresh worktrees"
}
},
@@ -13904,7 +14010,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@@ -14146,6 +14252,9 @@
]
}
},
"worktree": {
"$ref": "#/components/schemas/Config.Worktree"
},
"warming": {
"anyOf": [
{
@@ -14463,6 +14572,16 @@
},
"additionalProperties": false
},
"Config.Worktree": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"ConfigWebSearch.InfoEncoded": {
"type": "object",
"properties": {
@@ -18221,6 +18340,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@@ -18448,6 +18573,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {
@@ -19567,6 +19695,27 @@
"additionalProperties": false,
"description": "Reports whether this request destroyed an existing workspace."
},
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
},
"Worktree.Directory": {
"type": "object",
"properties": {
@@ -19596,6 +19745,19 @@
"$ref": "#/components/schemas/Worktree.Directory"
}
},
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
"type": "object",
"properties": {
@@ -19729,7 +19891,7 @@
},
{
"name": "worktree",
"description": "Project worktree management routes."
"description": "Location-scoped worktree management routes."
},
{
"name": "workspace",
+2 -2
View File
@@ -54,6 +54,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof WorktreeGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
@@ -88,7 +89,6 @@ type ApiGroups<
| typeof ServerGroup
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof WorkspaceGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
@@ -176,7 +176,7 @@ const makeApiFromGroup = <
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(WorktreeGroup.middleware(locationMiddleware))
.add(WorkspaceGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
+49 -41
View File
@@ -1,9 +1,9 @@
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Schema, Struct } from "effect"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/worktree/:projectID"
const root = "/api/worktree"
export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
{
@@ -16,61 +16,69 @@ export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.add(
HttpApiEndpoint.get("worktree.list", root, {
params: { projectID: Project.ID },
query: LocationQuery,
success: Worktree.List,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description: "List known local worktrees for a project.",
}),
),
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description:
"Discover worktrees through the requested location's strategies and return its project's inventory.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.create", root, {
params: { projectID: Project.ID },
payload: CreatePayload,
query: LocationQuery,
payload: Worktree.CreateInput,
success: Worktree.Info,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description: "Create a worktree for a project and run its configured setup script.",
}),
),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description:
"Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
}),
),
)
.add(
HttpApiEndpoint.delete("worktree.remove", root, {
params: { projectID: Project.ID },
payload: RemovePayload,
query: LocationQuery,
payload: Worktree.RemoveInput,
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from a project.",
}),
),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from the requested location's project using its recorded strategy.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
params: { projectID: Project.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Reconcile stored worktrees with the project repositories.",
}),
),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Discover worktrees from the requested location and reconcile the shared project inventory.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Location-scoped worktree management routes." }))
+6 -2
View File
@@ -20,6 +20,7 @@ import { ConfigWebSearch } from "./config/websearch.js"
import { ConfigToolOutput } from "./config/tool-output.js"
import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
import { ConfigWorktree } from "./config/worktree.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: optional(Schema.String).annotate({
@@ -34,8 +35,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
default_agent: Schema.String.pipe(optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
description: "Disable updates or notify when one is available",
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
description: "Disable updates, notify when one is available, or install updates automatically",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
@@ -98,6 +99,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
worktree: ConfigWorktree.Info.pipe(optional).annotate({
description: "Directory defaults for local worktree creation",
}),
warming: ConfigWarming.Warming.pipe(optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
+10
View File
@@ -0,0 +1,10 @@
export * as ConfigWorktree from "./worktree.js"
import { Schema } from "effect"
export const Info = Schema.Struct({
directory: Schema.Trim.pipe(Schema.check(Schema.isNonEmpty())).annotate({
description: "Parent directory for new worktrees, relative to the declaring config file when not absolute",
}),
}).annotate({ identifier: "Config.Worktree" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+15 -9
View File
@@ -2,7 +2,6 @@ export * as Worktree from "./worktree.js"
import { Schema } from "effect"
import { durable, ephemeral, inventory } from "./event.js"
import { ProjectID } from "./project-id.js"
import { AbsolutePath, optional } from "./schema.js"
import { Project } from "./project.js"
@@ -10,17 +9,18 @@ export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Sc
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
projectID: ProjectID,
strategy: StrategyID,
strategy: optional(StrategyID),
from: optional(AbsolutePath),
branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
directory: AbsolutePath,
directory: optional(AbsolutePath).annotate({
description:
"Parent directory for the new worktree. Uses the location's configuration, then defaults to the server's data directory under worktree/<first six project ID characters>.",
}),
name: optional(Schema.String),
}).annotate({ identifier: "Worktree.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const RemoveInput = Schema.Struct({
projectID: ProjectID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "Worktree.RemoveInput" })
@@ -37,10 +37,16 @@ export const Directory = Schema.Struct({
}).annotate({ identifier: "Worktree.Directory" })
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
export const ListInput = Schema.Struct({
projectID: ProjectID,
}).annotate({ identifier: "Worktree.ListInput" })
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
export class OperationError extends Schema.TaggedError<OperationError>()("Worktree.OperationError", {
message: Schema.String,
forceRequired: optional(Schema.Boolean),
}) {}
export const List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" })
export type List = typeof List.Type
+9
View File
@@ -10,6 +10,15 @@ import { AbsolutePath } from "../src/schema.js"
import { WebSearch } from "../src/websearch.js"
describe("Config.Entry", () => {
test("accepts directory-only worktree config and omits it when absent", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
const input = { worktree: { directory: "../worktrees" } }
expect(Schema.encodeSync(Config.Info)(decode(input))).toEqual(input)
expect(Schema.encodeSync(Config.Info)(new Config.Info({ worktree: undefined }))).not.toHaveProperty("worktree")
expect(() => decode({ worktree: {} })).toThrow()
expect(() => decode({ worktree: { directory: " " } })).toThrow()
expect(() => decode({ worktree: { directory: false } })).toThrow()
})
test("round-trips canonical provider IDs without changing config keys", () => {
const input = { providers: { "console-anthropic": { canonical: "anthropic" } } }
const decoded = Schema.decodeUnknownSync(Config.Info)(input)
@@ -184,7 +184,6 @@ describe("contract hygiene", () => {
Model.Variant,
Project.Current,
Worktree.Directory,
Worktree.ListInput,
Worktree.List,
Project.Icon,
Project.Commands,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Worktree } from "../src/worktree.js"
describe("Worktree.CreateInput", () => {
test("allows the server to choose the destination", () => {
const input = Schema.decodeUnknownSync(Worktree.CreateInput)({
strategy: "git",
})
expect(input.directory).toBeUndefined()
expect(Schema.encodeSync(Worktree.CreateInput)({ ...input, directory: undefined })).toEqual({
strategy: "git",
})
})
test("preserves an explicit destination", () => {
const input = { strategy: "git", directory: "/custom/worktrees" }
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)(input))).toEqual(
input,
)
})
})
test("worktree mutation inputs do not require a project or explicit creation defaults", () => {
const value = Schema.decodeUnknownSync(Worktree.CreateInput)({ name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(value)).toEqual({ name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)({}))).toEqual({})
expect(Worktree.CreateInput.fields).not.toHaveProperty("projectID")
expect(Worktree.RemoveInput.fields).not.toHaveProperty("projectID")
})
test("inventory contains only the directory and its owning strategy", () => {
const value = Schema.decodeUnknownSync(Worktree.Directory)({ directory: "/repo/task", strategy: "git" })
expect(Schema.encodeSync(Worktree.Directory)(value)).toEqual({ directory: "/repo/task", strategy: "git" })
})
test("strategy failures can request force confirmation without Core or Git dependencies", () => {
const value = new Worktree.OperationError({ message: "Dirty worktree", forceRequired: true })
expect(value.forceRequired).toBe(true)
expect(
Schema.encodeSync(Worktree.OperationError)(new Worktree.OperationError({ message: "Failed" })),
).not.toHaveProperty("forceRequired")
})
+2
View File
@@ -36,6 +36,8 @@
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
+24 -20
View File
@@ -1,32 +1,32 @@
import { Git } from "@opencode-ai/core/git"
import { Worktree } from "@opencode-ai/core/worktree"
import { Plugin } from "@opencode-ai/core/plugin"
import { WorktreeError } from "@opencode-ai/protocol/groups/worktree"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
return handlers
.handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID))
.handle("worktree.create", (ctx) =>
badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })),
)
.handle("worktree.remove", (ctx) =>
badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
.handle("worktree.refresh", (ctx) =>
badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
}),
handlers
.handle("worktree.list", () => run((worktrees) => worktrees.list()))
.handle("worktree.create", (ctx) => run((worktrees) => worktrees.create(ctx.payload)))
.handle("worktree.remove", (ctx) =>
run((worktrees) => worktrees.remove(ctx.payload)).pipe(Effect.as(HttpApiSchema.NoContent.make())),
)
.handle("worktree.refresh", () =>
run((worktrees) => worktrees.refresh()).pipe(Effect.as(HttpApiSchema.NoContent.make())),
),
)
function run<A>(action: (service: Worktree.Interface) => Effect.Effect<A, Worktree.Error>) {
return Effect.gen(function* () {
const plugins = yield* Plugin.Service
const worktrees = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* action(worktrees)
}).pipe(badRequest)
}
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
return effect.pipe(
Effect.mapError(
@@ -35,7 +35,10 @@ function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
name: "WorktreeError",
data: {
message: message(error),
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
forceRequired:
error instanceof Git.WorktreeError || error instanceof Worktree.OperationError
? error.forceRequired
: undefined,
},
}),
),
@@ -51,5 +54,6 @@ function message(error: Worktree.Error) {
if (error instanceof Worktree.DirectoryUnavailableError) return `Worktree directory unavailable: ${error.directory}`
if (error instanceof Worktree.InvalidDirectoryError) return `Invalid worktree directory: ${error.directory}`
if (error instanceof Worktree.StrategyUnavailableError) return `Worktree strategy unavailable: ${error.strategy}`
if (error instanceof Worktree.UnsupportedLocationError) return "Worktree operations only support local locations"
return error.message
}
+10 -1
View File
@@ -1,7 +1,9 @@
export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode-ai/core/bus"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
@@ -114,7 +116,14 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: shutdown.await }
const bus = Context.get(context, Bus.Service)
return {
address: bound.http.address,
shutdown: shutdown.await,
updateAvailable: (version: string) =>
bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid),
}
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
-2
View File
@@ -30,7 +30,6 @@ import { PluginUpdate } from "@opencode-ai/core/plugin/update"
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"
@@ -56,7 +55,6 @@ const applicationServiceNodes = [
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
Instance.node,
SessionTransfer.node,
@@ -0,0 +1,13 @@
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "test.worktree-delegate",
async setup(ctx) {
const directory = ctx.options.directory
if (typeof directory !== "string") throw new Error("Missing target location")
await ctx.worktree.create({
location: { directory },
name: "delegated",
})
},
})
@@ -0,0 +1,47 @@
import { Plugin, Worktree } from "@opencode-ai/plugin"
export default Plugin.define({
id: "test.worktree",
async setup(ctx) {
const id = typeof ctx.options.strategy === "string" ? ctx.options.strategy : "test-copy"
await ctx.worktree.transform((editor) =>
editor.add({
id,
async create(input, { signal }) {
await git(
input.sourceDirectory,
["worktree", "add", "--detach", "--", input.directory, input.branch ?? "HEAD"],
signal,
)
await ctx.storage.set(`tree:${input.directory}`, input.sourceDirectory)
return { directory: input.directory }
},
async remove(input, { signal }) {
const source = await ctx.storage.get(`tree:${input.directory}`)
if (typeof source !== "string") throw new Worktree.OperationError({ message: "Worktree source not found" })
// Windows cannot remove the working directory of the Git process itself.
await git(
source,
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
signal,
!input.force,
)
await ctx.storage.remove(`tree:${input.directory}`)
},
async list(sourceDirectory, { signal }) {
signal.throwIfAborted()
const rows = await ctx.storage.scan({ prefix: "tree:" })
return rows.entries
.filter((row) => row.value === sourceDirectory)
.map((row) => ({ directory: row.key.slice(5), type: "worktree" as const }))
},
}),
)
},
})
async function git(directory: string, args: string[], signal: AbortSignal, forceRequired = false) {
const child = Bun.spawn(["git", "-C", directory, ...args], { stdout: "pipe", stderr: "pipe", signal })
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
if (code !== 0) throw new Worktree.OperationError({ message: stderr, forceRequired })
}
+15 -1
View File
@@ -101,7 +101,13 @@ it.live("allows browser preflight requests without credentials", () =>
expect(event.headers.get("content-encoding")).toBeNull()
const body = event.body
if (!body) return yield* Effect.die(new Error("Event response has no body"))
yield* Effect.promise(() => body.cancel())
const reader = body.getReader()
yield* Effect.promise(() => readUntil(reader, "server.connected"))
yield* server.updateAvailable("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
yield* server.updated("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.updated"))
yield* Effect.promise(() => reader.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@@ -126,3 +132,11 @@ it.live("allows browser preflight requests without credentials", () =>
)
}),
)
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
while (true) {
const next = await reader.read()
if (next.done) throw new Error(`Event stream ended before ${expected}`)
if (new TextDecoder().decode(next.value).includes(expected)) return
}
}
+213 -47
View File
@@ -6,57 +6,223 @@ import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { OpenCode } from "@opencode-ai/client"
import { initRepo } from "../../core/test/fixture/git"
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const location = new URL("/api/location", server.base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() =>
fetch(location, { headers: server.headers }).then((response) => response.json()),
)
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, server.base)
it.live(
"lists, creates, and removes worktrees through the same location",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/worktree", server.base)
url.searchParams.set("location[directory]", project)
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({
directory: path.join(destination, "api"),
strategy: "git",
})
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
}),
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
}),
30_000,
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
it.live(
"derives the project and creation defaults when the SDK omits its input",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-default-location-")))
const project = path.join(tmp.path, "project")
const config = path.join(tmp.path, "config")
const destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await initRepo(project)
await fs.mkdir(config)
await Bun.write(path.join(config, "opencode.json"), JSON.stringify({ worktree: { directory: destination } }))
})
const server = yield* startServer(config)
const api = OpenCode.make({
baseUrl: server.base,
headers: { ...server.headers, "x-opencode-directory": encodeURIComponent(project) },
})
yield* Effect.promise(async () => {
const created = await api.worktree.create()
expect(path.dirname(created.directory)).toBe(destination)
await api.worktree.refresh()
expect(await api.worktree.list()).toContainEqual({
directory: created.directory,
strategy: "git",
})
await api.worktree.remove({ directory: created.directory, force: false })
expect(await api.worktree.list()).toEqual([{ directory: project }])
})
}),
30_000,
)
it.live(
"uses checkout-local plugins and configuration for clones sharing a project",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-plugins-")))
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const nested = path.join(first, "nested")
const config = path.join(tmp.path, "config")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(async () => {
await fs.mkdir(first)
await initRepo(first)
await $`git remote add origin git@github.com:example/worktree-fixture.git`.cwd(first).quiet()
await $`git clone --no-hardlinks ${first} ${second}`.quiet()
await $`git remote set-url origin https://github.com/example/worktree-fixture.git`.cwd(second).quiet()
await fs.mkdir(nested)
await fs.mkdir(config)
await Bun.write(path.join(config, "opencode.json"), JSON.stringify({ worktree: { directory: destination } }))
await Bun.write(
path.join(nested, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "test-copy" } },
],
}),
)
})
const server = yield* startServer(config)
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
const a = await api.location.get({ location: { directory: nested } })
const b = await api.location.get({ location: { directory: second } })
expect(a.project.id).toBe(b.project.id)
const custom = await api.worktree.create({ location: { directory: nested }, name: "custom" })
const builtin = await api.worktree.create({ location: { directory: second }, name: "builtin" })
expect(custom.directory).toBe(path.join(destination, "custom"))
expect(builtin.directory).toBe(path.join(destination, "builtin"))
const otherRows = await api.worktree.list({ location: { directory: second } })
expect(otherRows).toContainEqual({ directory: custom.directory, strategy: "test-copy" })
const rows = await api.worktree.list({ location: { directory: nested } })
expect(rows).toContainEqual({
directory: custom.directory,
strategy: "test-copy",
})
expect(rows).toContainEqual({ directory: builtin.directory, strategy: "git" })
await Bun.write(path.join(custom.directory, "dirty.txt"), "keep me")
const remove = new URL("/api/worktree", server.base)
remove.searchParams.set("location[directory]", second)
const unavailable = await fetch(remove, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: custom.directory, force: true }),
})
expect(unavailable.status).toBe(400)
expect(await unavailable.json()).toMatchObject({
data: { message: "Worktree strategy unavailable: test-copy" },
})
expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me")
remove.searchParams.set("location[directory]", nested)
const failure = await fetch(remove, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: custom.directory, force: false }),
})
expect(failure.status).toBe(400)
expect(await failure.json()).toMatchObject({ data: { forceRequired: true } })
expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me")
await api.worktree.remove({
location: { directory: nested },
directory: custom.directory,
force: true,
})
await api.worktree.remove({
location: { directory: second },
directory: builtin.directory,
force: false,
})
expect((await api.worktree.list({ location: { directory: nested } })).filter((row) => row.strategy)).toEqual([])
})
}),
30_000,
)
it.live(
"plugin calls await a different location's strategy and directory configuration",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-delegate-")))
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target")
const destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
for (const directory of [source, target]) {
await fs.mkdir(directory)
await initRepo(directory)
await $`git remote add origin git@github.com:example/delegate-fixture.git`.cwd(directory).quiet()
}
await Bun.write(
path.join(source, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-delegate"), options: { directory: target } },
],
}),
)
await Bun.write(
path.join(target, "opencode.json"),
JSON.stringify({
worktree: { directory: destination },
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "target-copy" } },
],
}),
)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
await api.location.get({ location: { directory: source } })
const url = new URL("/api/plugin/await-activation", server.base)
url.searchParams.set("location[directory]", source)
expect((await fetch(url, { method: "POST", headers: server.headers })).status).toBe(204)
expect(await api.worktree.list({ location: { directory: target } })).toContainEqual({
directory: path.join(destination, "delegated"),
strategy: "target-copy",
})
})
}),
30_000,
)
@@ -1,6 +1,6 @@
const providers = [
"opencode",
"opencode-go",
"opencode",
"anthropic",
"openai",
"google",
@@ -15,8 +15,8 @@ const provider = {
}
const popular = [
{ id: "opencode", name: "OpenCode Zen", models: {} },
{ id: "opencode-go", name: "OpenCode Go", models: {} },
{ id: "opencode", name: "OpenCode Zen", models: {} },
{ id: "openai", name: "OpenAI", models: {} },
provider,
{ id: "google", name: "Google", models: {} },
+52 -56
View File
@@ -64,7 +64,6 @@ import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogUpdate } from "./component/dialog-update"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@@ -88,6 +87,7 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
import { localPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { PanelProvider, usePanel } from "./context/panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -154,6 +155,7 @@ const appBindingCommands = [
"provider.connect",
"opencode.settings",
"opencode.status",
"opencode.update",
"server.pair",
"service.restart",
"opencode.debug",
@@ -185,10 +187,7 @@ export type TuiInput = {
}
args: Args
config: Config.Interface
updater?: {
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
apply: (version: string) => Promise<void>
}
updater?: UpdateSource
packages: PackageSource
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
@@ -397,22 +396,27 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
<UpdateNotificationProvider
updater={input.updater}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<PanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
@@ -462,7 +466,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -474,10 +478,12 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const dialog = useDialog()
const local = useLocal()
const sessionTabs = useSessionTabs()
const panels = usePanel()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
const toast = useToast()
const updater = useUpdateNotification()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const data = useData()
@@ -501,40 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
})
const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>(
"update-notifications",
{ initial: { versions: [] } },
)
const showUpdate = (version: string) => {
const updater = props.updater
if (!updater || updateNotifications.versions.includes(version)) return
void markUpdateNotification((draft) => {
draft.versions = [...draft.versions, version].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
const key = `update:${version}`
dialog.replace(
() => (
<DialogUpdate
dialogKey={key}
version={version}
install={() => updater.apply(version)}
restart={client.restart}
/>
),
undefined,
{ key },
)
dialog.setCentered(true)
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater.monitor(showUpdate, controller.signal).catch((error) => {
if (!controller.signal.aborted) log.error("update monitor failed", { error })
})
})
const tabsResize = createPaneResize({
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
defaultValue: () => SESSION_SIDEBAR_WIDTH,
@@ -608,9 +580,22 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const fullscreenPanel = () =>
route.data.type === "session" &&
panels.current()?.sessionID === route.data.sessionID &&
panels.presentation() === "fullscreen"
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
// Measure the prospective split layout, even while full-screen hides the tabs.
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
createEffect(() => {
const current = panels.current()
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
panels.close()
})
createEffect(() => {
renderer.useMouse = config.data.mouse
})
@@ -972,6 +957,17 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
},
category: "System",
},
...(updater.open
? [
{
name: "opencode.update",
title: "Update OpenCode",
slash: { name: "update" },
run: () => updater.open?.("manual"),
category: "System",
},
]
: []),
{
name: "server.pair",
title: "Pair device",
@@ -27,6 +27,7 @@ type ProjectDirectory = WorktreeListOutput[number]
type DialogMoveSessionProps = {
projectID: string
location?: { directory: string; workspaceID?: string }
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
@@ -45,7 +46,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const toast = useToast()
const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const location = createMemo(() => sessionData.location.info())
const location = createMemo(() => sessionData.location.info(props.location))
const worktreeLocation = () => ({
directory: props.location?.directory ?? location()?.directory ?? paths.cwd,
workspace: props.location?.workspaceID ?? location()?.workspaceID,
})
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -76,11 +81,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
const [directories, { refetch }] = createResource(
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
() => (props.fixture || props.initialRemoving ? undefined : worktreeLocation()),
async (location, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
await client.api.worktree.refresh({ projectID })
const directories = await client.api.worktree.list({ projectID })
const directories = await client.api.worktree.list({ location })
setLoadError(undefined)
return directories
} catch (error) {
@@ -223,10 +227,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setToDelete(undefined)
setRemoving(selected.directory)
setWorking(true)
const request = {
directory: selected.directory,
location: worktreeLocation(),
}
const error = await client.api.worktree
.remove({
projectID: props.projectID,
directory: selected.directory,
...request,
force: false,
})
.then(
@@ -251,8 +258,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
reopen(selected.directory)
const forcedError = await client.api.worktree
.remove({
projectID: props.projectID,
directory: selected.directory,
...request,
force: true,
})
.then(
+96 -109
View File
@@ -1,67 +1,56 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import type { UpdateState } from "../context/update-notification"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
import { Spinner } from "./spinner"
type State =
| { type: "ready"; active: "update" | "skip" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: {
dialogKey: string
version: string
check?: (signal: AbortSignal) => Promise<string | undefined>
state: () => UpdateState | undefined
install: () => Promise<void>
restart?: () => Promise<void>
restart: () => void
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
const close = () => {
if (dialog.key === props.dialogKey) dialog.clear()
}
const [error, setError] = createSignal<string>()
const [active, setActive] = createSignal(0)
const controller = new AbortController()
onCleanup(() => controller.abort())
const install = async () => {
setState({ type: "installing" })
await props.install()
if (props.restart) {
setState({ type: "restarting" })
await props.restart()
}
close()
}
dialog.setCentered(true)
const beginInstall = () => {
if (state().type !== "ready") return
void install().catch((error) => setState({ type: "failed", message: errorMessage(error) }))
}
const [check] = createResource(
() => props.check,
(check) =>
check(controller.signal).catch((error) => {
if (!controller.signal.aborted) setError(errorMessage(error))
return undefined
}),
)
const state = createMemo(() => {
if (check.loading) return { type: "checking" as const }
const unavailable = check()
if (unavailable) return { type: "unavailable" as const, message: unavailable }
const message = error()
if (message) return { type: "check-failed" as const, message }
return props.state() ?? { type: "current" as const }
})
const buttons = createMemo(() => {
const type = state().type
if (type === "installing") return []
const confirm =
type === "available"
? { label: "Update", run: props.install }
: type === "installed"
? { label: "Restart", run: props.restart }
: undefined
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
})
const run = () => {
const current = state()
if (current.type !== "ready") return
if (current.active === "skip") return close()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
)
const selected = (action: "update" | "skip") => {
const current = state()
return current.type === "ready" && current.active === action
}
const failure = () => {
const current = state()
return current.type === "failed" ? current.message : ""
}
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
Keymap.createLayer(() => ({
mode: "modal",
@@ -70,20 +59,17 @@ export function DialogUpdate(props: {
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (state().type === "failed" ? close() : run()),
run: () => void buttons()[active()]?.run(),
},
{
bind: "left",
title: "Previous update action",
...["left", "right", "tab", "shift+tab"].map((bind) => ({
bind,
title: bind === "left" || bind === "shift+tab" ? "Previous update action" : "Next update action",
group: "Dialog",
run: toggle,
},
{
bind: "right",
title: "Next update action",
group: "Dialog",
run: toggle,
},
run: () => {
const count = buttons().length
if (count) setActive((value) => (value + 1) % count)
},
})),
],
}))
@@ -91,64 +77,65 @@ export function DialogUpdate(props: {
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Update available
{state().type === "available" || state().type === "installing" || state().type === "failed"
? "Update available"
: "Update"}
</text>
<text fg={theme.text.subdued} onMouseUp={close}>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
An update is available. Applying will
{props.restart
? " restart the server and active sessions will be resumed."
: " install the update but you will need to manually restart."}
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner shimmer={theme.text.default}>Restarting the background service</Spinner>
</Match>
<Match when={state().type === "failed"}>
<text fg={theme.text.feedback.error.default}>{failure()}</text>
</Match>
</Switch>
<Show when={state()} keyed>
{(current) => (
<Switch>
<Match when={current.type === "checking"}>
<Spinner shimmer={theme.text.default}>Checking for updates</Spinner>
</Match>
<Match when={current.type === "available"}>
<text fg={theme.text.subdued}>
An update is available. After installing, you'll be prompted to restart OpenCode.
</text>
</Match>
<Match when={current.type === "installing"}>
<Spinner shimmer={theme.text.default}>
{current.type === "installing" ? `Installing OpenCode ${current.version}` : ""}
</Spinner>
</Match>
<Match when={current.type === "installed"}>
<text fg={theme.text.subdued} wrapMode="word">
Update successful! A restart is required. Any active sessions will be resumed automatically.
</text>
</Match>
<Match when={current.type === "current"}>
<text fg={theme.text.subdued}>OpenCode is already up to date.</text>
</Match>
<Match when={current.type === "unavailable"}>
<text fg={theme.text.subdued} wrapMode="word">
{current.type === "unavailable" ? current.message : ""}
</text>
</Match>
<Match when={current.type === "failed" || current.type === "check-failed"}>
<text fg={theme.text.feedback.error.default}>
{current.type === "failed" || current.type === "check-failed" ? current.message : ""}
</text>
</Match>
</Switch>
)}
</Show>
</box>
<Show
when={state().type === "ready"}
fallback={
<Show when={state().type === "failed"}>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={close}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
</box>
</Show>
}
>
<Show when={buttons().length > 0}>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["skip", "update"] as const}>
{(action) => (
<For each={buttons()}>
{(button, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "skip") return close()
beginInstall()
}}
backgroundColor={active() === index() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => void button.run()}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Skip"}
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.subdued}>
{button.label}
</text>
</box>
)}
+105
View File
@@ -0,0 +1,105 @@
import { RGBA, type OptimizedBuffer, type RenderContext, type TextOptions } from "@opentui/core"
import { extend, type JSX } from "@opentui/solid"
import { splitProps } from "solid-js"
import { useConfig } from "../config"
import { MaskedTextRenderable } from "./masked-text"
import { coast, smootherstep } from "./tab-pulse"
type FadeInTextOptions = TextOptions & {
backdrop?: RGBA
enabled?: boolean
sweepOffset?: number
sweepWidth?: number
}
const DURATION = 200
const FEATHER = 8
const clamp = (value: number) => Math.max(0, Math.min(1, value))
class FadeInTextRenderable extends MaskedTextRenderable {
private _backdrop = RGBA.defaultBackground()
private _enabled = true
private _sweepOffset = 0
private _sweepWidth: number | undefined
private elapsed = 0
constructor(ctx: RenderContext, options: FadeInTextOptions) {
super(ctx, options)
this.matrix[15] = 1
this.updateBackdrop()
if (options.backdrop) this.backdrop = options.backdrop
if (options.enabled === false) this.enabled = false
this.live = this._enabled
}
set backdrop(value: RGBA) {
if (value.equals(this._backdrop)) return
this._backdrop = value
this.updateBackdrop()
this.requestRender()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
this.live = value && this.elapsed < DURATION
this.requestRender()
}
set sweepOffset(value: number | undefined) {
this._sweepOffset = value ?? 0
this.requestRender()
}
set sweepWidth(value: number | undefined) {
this._sweepWidth = value
this.requestRender()
}
private updateBackdrop() {
this.matrix[3] = this._backdrop.r
this.matrix[7] = this._backdrop.g
this.matrix[11] = this._backdrop.b
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this._enabled || this.elapsed >= DURATION) return super.render(buffer, deltaTime)
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
this.elapsed = Math.min(DURATION, this.elapsed + deltaTime)
this.renderMasked(buffer, 1, (end) => {
const progress = this.elapsed / DURATION
const front = -FEATHER + coast(progress) * ((this._sweepWidth ?? end) + FEATHER * 2)
return (column) => 1 - smootherstep(clamp((front - (this._sweepOffset + column)) / FEATHER))
})
if (this.elapsed >= DURATION) this.live = false
}
}
extend({ fade_in_text: FadeInTextRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
fade_in_text: typeof FadeInTextRenderable
}
}
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & {
animate?: boolean
backdrop?: RGBA
sweepOffset?: number
sweepWidth?: number
}
export function FadeInText(props: Props) {
const config = useConfig().data
const [local, text] = splitProps(props, ["animate", "backdrop", "sweepOffset", "sweepWidth"])
return (
<fade_in_text
{...text}
backdrop={local.backdrop}
enabled={(local.animate ?? true) && (config.animations ?? true)}
sweepOffset={local.sweepOffset}
sweepWidth={local.sweepWidth}
/>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { OptimizedBuffer, RGBA, TargetChannel, TextRenderable } from "@opentui/core"
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
const CONTINUATION = 0xc0000000 | 0
export class MaskedTextRenderable extends TextRenderable {
protected readonly matrix = new Float32Array(16)
private scratch: OptimizedBuffer | undefined
private mask = new Float32Array(0)
protected renderMasked(
buffer: OptimizedBuffer,
initialStrength: number,
shade: (width: number) => (column: number) => number,
) {
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
this.scratch.clear(TRANSPARENT)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const intensity = shade(end)
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
let strength = initialStrength
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
// Wide glyph continuation cells retain the head cell's intensity.
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensity(column)
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
}
override destroy() {
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { BoxRenderable } from "@opentui/core"
import { onCleanup, onMount } from "solid-js"
import { usePanel, type PanelTarget } from "../context/panel"
import { InteractivityProvider } from "../context/interactivity"
import { ThemeContextProvider, useTheme } from "../context/theme"
import { Slot } from "../plugin/render"
export function PanelHost(props: {
panel: PanelTarget
width: number
focused: boolean
onFocus: () => void
onTarget: (node: BoxRenderable | undefined) => void
}) {
const panels = usePanel()
let node: BoxRenderable
onMount(() => props.onTarget(node))
onCleanup(() => props.onTarget(undefined))
const Content = () => {
const theme = useTheme()
return (
<box
id="session-panel"
ref={(value: BoxRenderable) => (node = value)}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
onMouseDown={props.onFocus}
>
<Slot
path="session.panel"
input={{
name: props.panel.name,
sessionID: props.panel.sessionID,
get width() {
return props.width
},
get presentation() {
return panels.presentation()
},
get focused() {
return props.focused
},
focus: props.onFocus,
close: panels.close,
toggleFullscreen: panels.toggleFullscreen,
}}
/>
</box>
)
}
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
</InteractivityProvider>
)
}
+34 -18
View File
@@ -54,6 +54,7 @@ import { resolvePastedAttachments } from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
@@ -186,6 +187,8 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const enabled = useInteractivity()
const disabled = () => props.disabled || !enabled()
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
@@ -257,6 +260,7 @@ export function Prompt(props: PromptProps) {
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: [
{
id: "session.cd",
@@ -346,8 +350,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.background.surface.offset
if (!props.disabled) input.cursorColor = theme.text.default
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
})
@@ -370,12 +373,13 @@ export function Prompt(props: PromptProps) {
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
if (disposed || input.isDestroyed || disabled()) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
disabled() ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
@@ -634,15 +638,18 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: !disabled(),
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
enabled: !disabled(),
bindings: [
"prompt.submit",
"prompt.editor",
@@ -660,12 +667,13 @@ export function Prompt(props: PromptProps) {
const ref: PromptRef = {
get focused() {
return input.focused
return !disabled() && input.focused
},
get current() {
return store.prompt
},
focus() {
if (disabled()) return
input.focus()
},
blur() {
@@ -719,11 +727,13 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (props.visible === false || disabled() || dialog.stack.length > 0) {
if (input.focused) input.blur()
input.focusable = false
return
}
input.focusable = true
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
if (!input.focused) input.focus()
@@ -919,13 +929,14 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: stashCommands(),
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
enabled: inputTarget() !== undefined && !disabled(),
bindings: ["prompt.paste"],
}
})
@@ -933,7 +944,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
bindings: ["prompt.clear"],
}
})
@@ -945,7 +956,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!disabled() &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
@@ -969,7 +980,7 @@ export function Prompt(props: PromptProps) {
return {
priority: 1,
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell",
commands: [
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
{
@@ -988,7 +999,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
@@ -1002,7 +1013,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1038,7 +1049,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -1073,6 +1084,7 @@ export function Prompt(props: PromptProps) {
let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") {
if (disabled()) return false
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -1096,7 +1108,6 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "text", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (move.creating()) return false
if (auto()?.visible) return false
const trimmed = store.prompt.text.trim()
@@ -1764,18 +1775,19 @@ export function Prompt(props: PromptProps) {
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
if (disabled()) {
e.preventDefault()
return
}
}}
onSubmit={() => {
if (disabled()) return
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
if (props.disabled) {
if (disabled()) {
event.preventDefault()
return
}
@@ -1811,12 +1823,16 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text.default
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
if (config.cursor) input.cursorStyle = config.cursor
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
if (disabled()) {
r.preventDefault()
return
}
if (r.button !== 0) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -1826,7 +1842,7 @@ export function Prompt(props: PromptProps) {
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">

Some files were not shown because too many files have changed in this diff Show More