Compare commits

..

1 Commits

Author SHA1 Message Date
Adam
2d65db95af fix(app): startup homepage flash 2026-03-13 09:50:37 -05:00
123 changed files with 2767 additions and 4682 deletions

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -77,7 +77,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -111,7 +111,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -138,7 +138,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -162,7 +162,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -186,7 +186,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -219,7 +219,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -250,7 +250,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -279,7 +279,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -295,7 +295,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.26",
"version": "1.2.25",
"bin": {
"opencode": "./bin/opencode",
},
@@ -416,7 +416,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -440,7 +440,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.26",
"version": "1.2.25",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -451,7 +451,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -486,7 +486,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -532,7 +532,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"zod": "catalog:",
},
@@ -543,7 +543,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -95,9 +95,6 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
model: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},

View File

@@ -13,9 +13,6 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
export const promptModelSelector = '[data-component="prompt-model-control"]'
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'

View File

@@ -1,351 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
} from "../selectors"
import { createSdk, sessionPath } from "../utils"
type Footer = {
agent: string
model: string
variant: string
}
type Probe = {
dir?: string
sessionID?: string
model?: { providerID: string; modelID: string }
}
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
}
}
async function waitFooter(page: Page, expected: Partial<Footer>) {
let hit: Footer | null = null
await expect
.poll(
async () => {
const state = await read(page)
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
if (ok) hit = state
return ok
},
{ timeout: 30_000 },
)
.toBe(true)
if (!hit) throw new Error("Failed to resolve prompt footer state")
return hit
}
async function waitModel(page: Page, value: string) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
}
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const item = page
.locator('[data-slot="select-select-item"]')
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
.first()
await expect(item).toBeVisible()
await item.click()
}
async function variantCount(page: Page) {
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const count = await page.locator('[data-slot="select-select-item"]').count()
await page.keyboard.press("Escape")
return count
}
async function agents(page: Page) {
const select = page.locator(promptAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
await page.keyboard.press("Escape")
return labels.map((item) => item.trim()).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
const current = await read(page)
if ((await variantCount(page)) >= 2) return current
const cfg = await createSdk(directory)
.config.get()
.then((x) => x.data)
const visible = new Set(await agents(page))
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
const value = item[1]
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
})
const name = entry?.[0]
test.skip(!name, "no agent with alternate variants available")
if (!name) return current
await choose(page, promptAgentSelector, name)
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
return waitFooter(page, { agent: name })
}
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
if (count < 2) throw new Error("Current model has no alternate variant to select")
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
if (!next || next === current.variant) continue
await item.click()
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
throw new Error("Failed to choose a different variant")
}
async function chooseOtherModel(page: Page): Promise<Footer> {
const current = await read(page)
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
await expect(button).toBeVisible()
await button.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const items = dialog.locator('[data-slot="list-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const selected = (await item.getAttribute("data-selected")) === "true"
if (selected) continue
await item.click()
await expect(dialog).toHaveCount(0)
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
return read(page)
}
throw new Error("Failed to choose a different model")
}
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
}
async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function waitWorkspace(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function newWorkspaceSession(page: Page, slug: string) {
await waitWorkspace(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
}
test("session model and variant restore per session without leaking into new sessions", async ({
page,
withProject,
}) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await waitFooter(page, firstState)
await gotoSession()
const fresh = await ensureVariant(page, directory)
expect(fresh.variant).not.toBe(firstState.variant)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await waitFooter(page, secondState)
await gotoSession()
await waitFooter(page, fresh)
})
})
test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
await ensureVariant(page, root)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
await ensureVariant(page, twoDir)
const thirdState = await chooseDifferentVariant(page)
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first)
await waitFooter(page, firstState)
await goto(page, oneDir, second)
await waitFooter(page, secondState)
await goto(page, twoDir, third)
await waitFooter(page, thirdState)
await goto(page, root, first)
await waitFooter(page, firstState)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.26",
"version": "1.2.25",
"description": "",
"type": "module",
"exports": {

View File

@@ -66,7 +66,6 @@ export const DialogFork: Component = () => {
directory: sdk.directory,
attachmentName: language.t("common.attachment"),
})
const dir = base64Encode(sdk.directory)
sdk.client.session
.fork({ sessionID, messageID: item.id })
@@ -76,8 +75,10 @@ export const DialogFork: Component = () => {
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)

View File

@@ -13,10 +13,8 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model
export const DialogSelectModelUnpaid: Component = () => {
const local = useLocal()
const dialog = useDialog()
const providers = useProviders()
const language = useLanguage()
@@ -37,8 +35,8 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<List
class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
items={model.list}
current={model.current()}
items={local.model.list}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => (
<Tooltip
@@ -57,7 +55,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()

View File

@@ -18,22 +18,19 @@ import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
action?: JSX.Element
model?: ModelState
}> = (props) => {
const model = props.model ?? useLocal().model
const local = useLocal()
const language = useLanguage()
const models = createMemo(() =>
model
local.model
.list()
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
@@ -44,7 +41,7 @@ const ModelList: Component<{
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={model.current()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
@@ -66,7 +63,7 @@ const ModelList: Component<{
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
@@ -91,7 +88,6 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
@@ -155,7 +151,6 @@ export function ModelSelectorPopover(props: {
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList
provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)}
class="p-1"
action={
@@ -189,7 +184,7 @@ export function ModelSelectorPopover(props: {
)
}
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
@@ -207,7 +202,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
</Button>
}
>
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"

View File

@@ -2,6 +2,7 @@ import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import {
@@ -120,7 +121,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const inset = 56
const inset = 52
const space = `${inset}px`
const scrollCursorIntoView = () => {
@@ -410,6 +411,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click()
@@ -1012,6 +1014,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => {
@@ -1028,17 +1031,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
})
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({
info,
@@ -1345,7 +1337,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
/>
<div class="flex items-center gap-1 pointer-events-auto">
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
@@ -1382,30 +1400,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div
aria-hidden={store.mode !== "normal"}
class="pointer-events-auto"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
gutter={8}
title={language.t(
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
)}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-attach"
type="button"
data-action="prompt-permissions"
variant="ghost"
class="size-8 p-0"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
onClick={() => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
"size-6 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
aria-label={
accepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={accepting()}
>
<Icon name="plus" class="size-4.5" />
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": accepting() }}
/>
</Button>
</TooltipKeybind>
</div>
@@ -1427,76 +1457,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<div data-component="prompt-agent-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
<Button
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
@@ -1509,52 +1502,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</Button>
</TooltipKeybind>
</Show>
</div>
<div data-component="prompt-variant-control">
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</div>
</Show>
<TooltipKeybind
placement="top"
gutter={8}
title={acceptLabel()}
keybind={command.keybind("permissions.autoaccept")}
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Button
data-action="prompt-permissions"
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
variant="ghost"
onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
/>
</TooltipKeybind>
</div>
</div>

View File

@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
@@ -23,22 +22,3 @@ describe("attachmentMime", () => {
expect(await attachmentMime(file)).toBeUndefined()
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
})
test("uses manual paste for multiline text", () => {
expect(
pasteMode(`{
"ok": true
}`),
).toBe("manual")
expect(pasteMode("a\r\nb")).toBe("manual")
})
test("uses manual paste for large text", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})

View File

@@ -5,7 +5,8 @@ import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
@@ -24,8 +25,20 @@ function dataUrl(file: File, mime: string) {
})
}
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
@@ -78,6 +91,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
const handlePaste = async (event: ClipboardEvent) => {
if (!input.isFocused()) return
const clipboardData = event.clipboardData
if (!clipboardData) return
@@ -112,23 +126,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (!plainText) return
const text = normalizePaste(plainText)
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
input.focusEditor()
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
}
if (pasteMode(text) === "manual") {
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return
put()
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
const handleGlobalDragOver = (event: DragEvent) => {

View File

@@ -1,24 +0,0 @@
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
export function normalizePaste(text: string) {
if (!text.includes("\r")) return text
return text.replace(/\r\n?/g, "\n")
}
export function pasteMode(text: string) {
if (largePaste(text)) return "manual"
if (text.includes("\n") || text.includes("\r")) return "manual"
return "native"
}

View File

@@ -17,7 +17,6 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
@@ -87,11 +86,6 @@ beforeAll(async () => {
agent: {
current: () => ({ name: "agent" }),
},
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
},
}),
}))
@@ -207,7 +201,6 @@ beforeEach(() => {
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
@@ -247,11 +240,6 @@ describe("prompt submit worktree selection", () => {
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
})
test("applies auto-accept to newly created sessions", async () => {

View File

@@ -296,7 +296,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current()
const currentAgent = local.agent.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -371,7 +370,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
@@ -389,6 +387,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
const context = prompt.context.items().slice()
const draft: FollowupDraft = {
sessionID: session.id,

View File

@@ -16,11 +16,9 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
@@ -134,7 +132,6 @@ export function SessionHeader() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -221,9 +218,6 @@ export function SessionHeader() {
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
@@ -326,7 +320,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
@@ -336,9 +330,10 @@ export function SessionHeader() {
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
<Spinner class="size-3.5 text-icon-base" />
</Show>
</div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button>
<DropdownMenu
gutter={4}

View File

@@ -217,49 +217,26 @@ export function Titlebar() {
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</Show>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"

View File

@@ -1,421 +1,252 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers"
import { modelEnabled, modelProbe } from "@/testing/model-selection"
import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { batch, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string }
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
type Saved = {
session: Record<string, State | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return undefined
return {
...value,
model: value.model ? { ...value.model } : undefined,
} satisfies State
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const providers = useProviders()
const models = useModels()
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
session: {},
}),
)
const [store, setStore] = createStore<{
current?: string
draft?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
const provider = providers.all().find((item) => item.id === model.providerID)
function isModelValid(model: ModelKey) {
const provider = providers.all().find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const firstModel = (...items: Array<() => ModelKey | undefined>) => {
for (const item of items) {
const model = item()
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (validModel(model)) return model
if (isModelValid(model)) return model
}
}
const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return undefined
return items.find((item) => item.name === name) ?? items[0]
}
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
createEffect(() => {
const items = list()
if (items.length === 0) {
if (store.current !== undefined) setStore("current", undefined)
return
}
if (items.some((item) => item.name === store.current)) return
setStore("current", items[0]?.name)
})
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const models = useModels()
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(sdk.directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
handoff.delete(key)
return
}
setSaved("session", session, clone(next))
handoff.delete(key)
})
const configuredModel = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
const recentModel = () => {
for (const item of models.recent.list()) {
if (validModel(item)) return item
}
}
const defaultModel = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const model = { providerID: provider.id, modelID: configured }
if (validModel(model)) return model
}
const first = Object.values(provider.models)[0]
if (!first) continue
const model = { providerID: provider.id, modelID: first.id }
if (validModel(model)) return model
}
}
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
const agent = {
list,
current() {
return pickAgent(scope()?.agent ?? store.current)
},
set(name: string | undefined) {
const item = pickAgent(name)
if (!item) {
setStore("current", undefined)
return
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const next = {
agent: item.name,
model: item.model,
variant: item.variant,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
const [store, setStore] = createStore<{
current?: string
}>({
current: list()[0]?.name,
})
return {
list,
current() {
const available = list()
if (available.length === 0) return undefined
return available.find((x) => x.name === store.current) ?? available[0]
},
set(name: string | undefined) {
const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
setStore("draft", next)
})
},
move(direction: 1 | -1) {
const items = list()
if (items.length === 0) {
setStore("current", undefined)
return
}
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
const item = items[next]
if (!item) return
agent.set(item.name)
},
}
const current = () => {
const item = firstModel(
() => scope()?.model,
() => agent.current()?.model,
fallback,
)
if (!item) return undefined
return models.find(item)
}
const configured = () => {
const item = agent.current()
const model = current()
if (!item || !model) return undefined
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
}
const selected = () => scope()?.variant
const snapshot = () => {
const model = current()
return {
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...next,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, state)
return
const match = name ? available.find((x) => x.name === name) : undefined
const value = match ?? available[0]
if (!value) return
setStore("current", value.name)
if (!value.model) return
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
},
move(direction: 1 | -1) {
const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
let next = available.findIndex((x) => x.name === store.current) + direction
if (next < 0) next = available.length - 1
if (next >= available.length) next = 0
const value = available[next]
if (!value) return
setStore("current", value.name)
if (!value.model) return
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
},
}
setStore("draft", state)
}
})()
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const model = (() => {
const models = useModels()
const model = {
ready: models.ready,
current,
recent,
list: models.list,
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey | undefined>
}>({
model: {},
})
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
const resolveConfigured = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const key = { providerID, modelID }
if (isModelValid(key)) return key
}
const resolveRecent = () => {
for (const item of models.recent.list()) {
if (isModelValid(item)) return item
}
}
const resolveDefault = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(provider.models)[0]
if (!first) continue
const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key
}
}
const fallbackModel = createMemo<ModelKey | undefined>(() => {
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
})
const current = createMemo(() => {
const a = agent.current()
if (!a) return undefined
const key = getFirstValidModel(
() => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)
if (!key) return undefined
return models.find(key)
})
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const currentModel = current()
if (!currentModel) return
const index = recentList.findIndex(
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
)
if (index === -1) return
let next = index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
if (next < 0) next = recentList.length - 1
if (next >= recentList.length) next = 0
const entry = items[next]
if (!entry) return
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
const val = recentList[next]
if (!val) return
model.set({
providerID: val.provider.id,
modelID: val.id,
})
},
visible(item: ModelKey) {
return models.visible(item)
},
setVisibility(item: ModelKey, visible: boolean) {
models.setVisibility(item, visible)
},
variant: {
configured,
selected,
current() {
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
}
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
batch(() => {
const currentAgent = agent.current()
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) models.setVisibility(model, true)
if (options?.recent && model) models.recent.push(model)
})
}
setModel = set
return {
ready: models.ready,
current,
recent,
list: models.list,
cycle,
set,
visible(model: ModelKey) {
return models.visible(model)
},
list() {
const item = current()
if (!item?.variants) return []
return Object.keys(item.variants)
setVisibility(model: ModelKey, visible: boolean) {
models.setVisibility(model, visible)
},
set(value: string | undefined) {
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
variant: {
configured() {
const a = agent.current()
const m = current()
if (!a || !m) return undefined
return getConfiguredAgentVariant({
agent: { model: a.model, variant: a.variant },
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
})
write({ variant: value ?? null })
})
},
cycle() {
const items = this.list()
if (items.length === 0) return
this.set(
cycleModelVariant({
variants: items,
},
selected() {
const m = current()
if (!m) return undefined
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
},
current() {
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
}),
)
})
},
list() {
const m = current()
if (!m) return []
if (!m.variants) return []
return Object.keys(m.variants)
},
set(value: string | undefined) {
const m = current()
if (!m) return
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
},
}
}
})()
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
session: {
reset() {
setStore("draft", undefined)
},
promote(dir: string, session: string) {
const next = clone(snapshot())
if (!next) return
if (dir === sdk.directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.variant ?? null,
})
},
},
}
if (modelEnabled()) {
createEffect(() => {
const agent = result.agent.current()
const model = result.model.current()
modelProbe.set({
dir: sdk.directory,
sessionID: id(),
last: store.last,
agent: agent?.name,
model: model
? {
providerID: model.provider.id,
modelID: model.id,
name: model.name,
}
: undefined,
variant: result.model.variant.current() ?? null,
selected: result.model.variant.selected(),
configured: result.model.variant.configured(),
pick: scope(),
base: undefined,
current: store.current,
})
})
onCleanup(() => modelProbe.clear())
}
return result
},
})

View File

@@ -44,16 +44,6 @@ describe("model variant", () => {
expect(value).toBe("high")
})
test("lets an explicit default override the configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBeUndefined()
})
test("cycles from configured variant to next", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
@@ -73,14 +63,4 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("cycles from an explicit default to the first variant", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBe("low")
})
})

View File

@@ -14,7 +14,7 @@ type Model = AgentModel & {
type VariantInput = {
variants: string[]
selected: string | null | undefined
selected: string | undefined
configured: string | undefined
}
@@ -29,7 +29,6 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
@@ -37,7 +36,6 @@ export function resolveModelVariant(input: VariantInput) {
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined

View File

@@ -151,11 +151,6 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type Scope = {
dir: string
id?: string
}
type PromptCacheEntry = {
value: PromptSession
dispose: VoidFunction
@@ -270,7 +265,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return {
ready: () => session().ready(),
@@ -286,8 +280,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(),
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
}
},
})

View File

@@ -80,11 +80,11 @@ export default function Layout(props: ParentProps) {
})
return (
<Show when={state.resolved} keyed>
<Show when={state.resolved}>
{(resolved) => (
<SDKProvider directory={() => resolved}>
<SDKProvider directory={resolved}>
<SyncProvider>
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
)}

View File

@@ -1,16 +1,4 @@
import {
batch,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
ParentProps,
Show,
untrack,
type Accessor,
} from "solid-js"
import { batch, createEffect, createMemo, For, on, onCleanup, onMount, ParentProps, Show, untrack } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
@@ -140,6 +128,7 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({
autoselect: !initialDirectory,
routing: false,
busyWorkspaces: {} as Record<string, boolean>,
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
@@ -147,11 +136,12 @@ export default function Layout(props: ParentProps) {
nav: undefined as HTMLElement | undefined,
sortNow: Date.now(),
sizing: false,
peek: undefined as string | undefined,
peek: undefined as LocalProject | undefined,
peeked: false,
})
const editor = createInlineEditorController()
let token = 0
const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory)
if (value) {
@@ -245,12 +235,6 @@ export default function Layout(props: ParentProps) {
return layout.projects.list().find((project) => project.worktree === id)
})
const peekProject = createMemo(() => {
const id = state.peek
if (!id) return
return layout.projects.list().find((project) => project.worktree === id)
})
createEffect(() => {
const p = hoverProjectData()
if (p) {
@@ -258,7 +242,7 @@ export default function Layout(props: ParentProps) {
clearTimeout(peekt)
peekt = undefined
}
setState("peek", p.worktree)
setState("peek", p)
setState("peeked", true)
return
}
@@ -279,6 +263,7 @@ export default function Layout(props: ParentProps) {
const autoselecting = createMemo(() => {
if (params.dir) return false
if (state.routing) return true
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
@@ -288,12 +273,16 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
if (!state.autoselect) return
if (!state.autoselect && !state.routing) return
const dir = params.dir
if (!dir) return
const directory = decode64(dir)
if (!directory) return
setState("autoselect", false)
token += 1
batch(() => {
setState("autoselect", false)
setState("routing", false)
})
})
const editorOpen = editor.editorOpen
@@ -579,23 +568,32 @@ export default function Layout(props: ParentProps) {
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (state.routing) return
if (value.dir) return
const last = server.projects.last()
if (value.list.length === 0) {
if (!last) return
setState("autoselect", false)
openProject(last, false)
navigateToProject(last)
return
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
const next =
value.list.length === 0
? last
: (value.list.find((project) => project.worktree === last)?.worktree ?? value.list[0]?.worktree)
if (!next) return
setState("autoselect", false)
openProject(next.worktree, false)
navigateToProject(next.worktree)
const id = ++token
batch(() => {
setState("autoselect", false)
setState("routing", true)
})
void navigateToProject(next, () => id === token && !params.dir).then(
(navigated) => {
if (id !== token) return
if (navigated) return
setState("routing", false)
},
() => {
if (id !== token) return
setState("routing", false)
},
)
},
),
)
@@ -1229,14 +1227,19 @@ export default function Layout(props: ParentProps) {
return root
}
async function navigateToProject(directory: string | undefined) {
if (!directory) return
async function navigateToProject(directory: string | undefined, live = () => true) {
if (!directory || !live()) return false
const root = projectRoot(directory)
server.projects.touch(root)
const touch = () => {
if (!live()) return false
layout.projects.open(root)
server.projects.touch(root)
return true
}
const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const sandboxes =
project?.sandboxes ?? globalSync.data.project.find((item) => item.worktree === root)?.sandboxes ?? []
let dirs = effectiveWorkspaceOrder(root, [root, ...sandboxes], store.workspaceOrder[root])
const canOpen = (value: string | undefined) => {
if (!value) return false
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
@@ -1247,13 +1250,16 @@ export default function Layout(props: ParentProps) {
.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
if (!live()) return false
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
}
const openSession = async (target: { directory: string; id: string }) => {
if (!live()) return false
if (!canOpen(target.directory)) return false
const [data] = globalSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
if (!touch()) return false
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
@@ -1262,8 +1268,10 @@ export default function Layout(props: ParentProps) {
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
if (!live()) return false
if (!resolved?.directory) return false
if (!canOpen(resolved.directory)) return false
if (!touch()) return false
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true
@@ -1272,19 +1280,23 @@ export default function Layout(props: ParentProps) {
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
await refreshDirs(projectSession.directory)
if (!live()) return false
const opened = await openSession(projectSession)
if (opened) return
if (opened) return true
if (!live()) return false
clearLastProjectSession(root)
}
if (!live()) return false
const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession(latest))) {
return
return true
}
if (!live()) return false
const fetched = latestRootSession(
await Promise.all(
dirs.map(async (item) => ({
@@ -1297,11 +1309,14 @@ export default function Layout(props: ParentProps) {
),
Date.now(),
)
if (!live()) return false
if (fetched && (await openSession(fetched))) {
return
return true
}
if (!touch()) return false
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
return true
}
function navigateToSession(session: Session | undefined) {
@@ -1950,32 +1965,17 @@ export default function Layout(props: ParentProps) {
setHoverSession,
}
const SidebarPanel = (panelProps: {
project: Accessor<LocalProject | undefined>
mobile?: boolean
merged?: boolean
}) => {
const project = panelProps.project
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const projectName = createMemo(() => {
const item = project()
if (!item) return ""
return item.name || getFilename(item.worktree)
})
const projectId = createMemo(() => project()?.id ?? "")
const worktree = createMemo(() => project()?.worktree ?? "")
const slug = createMemo(() => {
const dir = worktree()
if (!dir) return ""
return base64Encode(dir)
})
const workspaces = createMemo(() => {
const item = project()
if (!item) return [] as string[]
return workspaceIds(item)
const project = panelProps.project
if (!project) return ""
return project.name || getFilename(project.worktree)
})
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
@@ -1984,15 +1984,10 @@ export default function Layout(props: ParentProps) {
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => {
const item = project()
if (!item) return false
if (item.vcs !== "git") return false
return layout.sidebar.workspaces(item.worktree)()
})
const canToggle = createMemo(() => {
const item = project()
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
const project = panelProps.project
if (!project) return false
if (project.vcs !== "git") return false
return layout.sidebar.workspaces(project.worktree)()
})
const homedir = createMemo(() => globalSync.data.path.home)
@@ -2011,197 +2006,168 @@ export default function Layout(props: ParentProps) {
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
>
<Show when={project()}>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Show when={panelProps.project}>
{(p) => (
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p(), next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
createWorkspace(item)
<Tooltip
placement="bottom"
gutter={2}
value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
{language.t("workspace.new")}
</Button>
<span class="text-12-regular text-text-base truncate select-text">
{p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
)}
</Show>
<div
@@ -2261,10 +2227,10 @@ export default function Layout(props: ParentProps) {
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() =>
mobile ? (
<SidebarPanel project={currentProject} mobile />
<SidebarPanel project={currentProject()} mobile />
) : (
<Show when={currentProject()}>
<SidebarPanel project={currentProject} merged />
<SidebarPanel project={currentProject()} merged />
</Show>
)
}
@@ -2392,8 +2358,8 @@ export default function Layout(props: ParentProps) {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
<Show when={state.peek}>
<SidebarPanel project={state.peek} merged={false} />
</Show>
</div>

View File

@@ -9,13 +9,14 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { type Accessor, createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { agentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
@@ -101,46 +102,94 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
cancelHoverPrefetch: () => void
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
}): JSX.Element => {
const [slot, setSlot] = createStore({
open: false,
show: false,
fade: false,
})
let f: number | undefined
const clear = () => {
if (f !== undefined) window.clearTimeout(f)
f = undefined
}
onCleanup(clear)
createEffect(
on(
() => props.isWorking(),
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`relative flex items-center min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<Show when={!props.isWorking() && (props.hasPermissions() || props.hasError() || props.unseenCount() > 0)}>
<div
classList={{
"absolute left-0 top-1/2 -translate-y-1/2 size-1.5 rounded-full": true,
"bg-surface-warning-strong": props.hasPermissions(),
"bg-text-diff-delete-base": !props.hasPermissions() && props.hasError(),
"bg-text-interactive-base": !props.hasPermissions() && !props.hasError() && props.unseenCount() > 0,
}}
aria-hidden="true"
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div>
</A>
)
</A>
)
}
const SessionHoverPreview = (props: {
mobile?: boolean
@@ -219,7 +268,19 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
})
const tint = createMemo(() => {
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
const messages = sessionStore.message[props.session.id]
if (!messages) return undefined
let user: Message | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== "user") continue
user = message
break
}
if (!user?.agent) return undefined
const agent = sessionStore.agent.find((a) => a.name === user.agent)
return agentColor(user.agent, agent?.color)
})
const hoverMessages = createMemo(() =>
@@ -298,7 +359,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
class="group/session relative w-full rounded-md cursor-default pl-3 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show
@@ -384,7 +445,7 @@ export const NewSessionItem = (props: {
>
<div class="flex items-center gap-1 w-full">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" />
<Icon name="plus-small" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label}

View File

@@ -217,7 +217,7 @@ const WorkspaceActions = (props: {
<Show when={!props.touch()}>
<Tooltip value={props.language.t("command.session.new")} placement="top">
<IconButton
icon="new-session"
icon="plus-small"
variant="ghost"
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
data-action="workspace-new-session"

View File

@@ -44,7 +44,7 @@ import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalByI
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
@@ -490,7 +490,7 @@ export default function Page() {
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
if (prev.id && !next.id) local.session.reset()
if (!next.id) resetSessionModel(local)
},
{ defer: true },
),
@@ -1477,7 +1477,6 @@ export default function Page() {
const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID)
const dir = base64Encode(sdk.directory)
return sdk.client.session
.fork(input)
.then((result) => {
@@ -1489,8 +1488,10 @@ export default function Page() {
})
return
}
prompt.set(value, undefined, { dir, id: next.id })
navigate(`/${dir}/session/${next.id}`)
navigate(`/${base64Encode(sdk.directory)}/session/${next.id}`)
requestAnimationFrame(() => {
prompt.set(value)
})
})
.catch(fail)
}

View File

@@ -27,7 +27,6 @@ import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
type MessageComment = {
@@ -247,7 +246,6 @@ export function MessageTimeline(props: {
return sync.data.session_status[id] ?? idle
})
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({
open: false,
@@ -691,7 +689,7 @@ export function MessageTimeline(props: {
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>

View File

@@ -14,38 +14,145 @@ const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant"
}) as UserMessage
describe("syncSessionModel", () => {
test("restores the last message through session state", () => {
test("restores the last message model and variant", () => {
const calls: unknown[] = []
syncSessionModel(
{
session: {
restore(value) {
calls.push(value)
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
reset() {},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([message({ variant: "high" })])
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("skips variant when the model falls back", () => {
const calls: unknown[] = []
syncSessionModel(
{
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "gpt-5", provider: { id: "openai" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
])
})
})
describe("resetSessionModel", () => {
test("clears draft session state", () => {
const calls: string[] = []
test("restores the current agent defaults", () => {
const calls: unknown[] = []
resetSessionModel({
session: {
reset() {
calls.push("reset")
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: "high",
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
restore() {},
},
})
expect(calls).toEqual(["reset"])
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("clears the variant when the agent has none", () => {
const calls: unknown[] = []
resetSessionModel({
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
})
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", undefined],
])
})
})

View File

@@ -1,16 +1,48 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { batch } from "solid-js"
type Local = {
session: {
reset(): void
restore(msg: UserMessage): void
agent: {
current():
| {
model?: UserMessage["model"]
variant?: string
}
| undefined
set(name: string | undefined): void
}
model: {
set(model: UserMessage["model"] | undefined): void
current():
| {
id: string
provider: { id: string }
}
| undefined
variant: {
set(value: string | undefined): void
}
}
}
export const resetSessionModel = (local: Local) => {
local.session.reset()
const agent = local.agent.current()
if (!agent) return
batch(() => {
local.model.set(agent.model)
local.model.variant.set(agent.variant)
})
}
export const syncSessionModel = (local: Local, msg: UserMessage) => {
local.session.restore(msg)
batch(() => {
local.agent.set(msg.agent)
local.model.set(msg.model)
})
const model = local.model.current()
if (!model) return
if (model.provider.id !== msg.model.providerID) return
if (model.id !== msg.model.modelID) return
local.model.variant.set(msg.variant)
}

View File

@@ -351,7 +351,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
onSelect: () => dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",

View File

@@ -1,80 +0,0 @@
type ModelKey = {
providerID: string
modelID: string
}
type State = {
agent?: string
model?: ModelKey | null
variant?: string | null
}
export type ModelProbeState = {
dir?: string
sessionID?: string
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
agent?: string
model?: (ModelKey & { name?: string }) | undefined
variant?: string | null
selected?: string | null
configured?: string
pick?: State
base?: State
current?: string
}
export type ModelWindow = Window & {
__opencode_e2e?: {
model?: {
enabled?: boolean
current?: ModelProbeState
}
}
}
const clone = (state?: State) => {
if (!state) return undefined
return {
...state,
model: state.model ? { ...state.model } : state.model,
}
}
export const modelEnabled = () => {
if (typeof window === "undefined") return false
return (window as ModelWindow).__opencode_e2e?.model?.enabled === true
}
const root = () => {
if (!modelEnabled()) return
return (window as ModelWindow).__opencode_e2e?.model
}
export const modelProbe = {
set(input: ModelProbeState) {
const state = root()
if (!state) return
state.current = {
...input,
model: input.model ? { ...input.model } : undefined,
last: input.last
? {
...input.last,
model: input.last.model ? { ...input.last.model } : input.last.model,
}
: undefined,
pick: clone(input.pick),
base: clone(input.base),
}
},
clear() {
const state = root()
if (!state) return
state.current = undefined
},
}

View File

@@ -1,5 +1,3 @@
import type { ModelProbeState } from "./model-selection"
export const terminalAttr = "data-pty-id"
export type TerminalProbeState = {
@@ -15,10 +13,6 @@ type TerminalProbeControl = {
export type E2EWindow = Window & {
__opencode_e2e?: {
model?: {
enabled?: boolean
current?: ModelProbeState
}
terminal?: {
enabled?: boolean
terminals?: Record<string, TerminalProbeState>

View File

@@ -9,15 +9,3 @@ export function agentColor(name: string, custom?: string) {
if (custom) return custom
return defaults[name] ?? defaults[name.toLowerCase()]
}
export function messageAgentColor(
list: readonly { role: string; agent?: string }[] | undefined,
agents: readonly { name: string; color?: string }[],
) {
if (!list) return undefined
for (let i = list.length - 1; i >= 0; i--) {
const item = list[i]
if (item.role !== "user" || !item.agent) continue
return agentColor(item.agent, agents.find((agent) => agent.name === item.agent)?.color)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.26",
"version": "1.2.25",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.26",
"version": "1.2.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.26",
"version": "1.2.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.26",
"version": "1.2.25",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.26"
version = "1.2.25"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.26",
"version": "1.2.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -34,7 +34,6 @@ Instructions to follow when writing Effect.
- Use `Effect.gen(function* () { ... })` for composition.
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
## Time
@@ -43,37 +42,3 @@ Instructions to follow when writing Effect.
## Errors
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
## Instance-scoped Effect services
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
### Instance.bind — ALS context for native callbacks
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
```typescript
// Native addon callback — needs Instance.bind
const cb = Instance.bind((err, evts) => {
Bus.publish(MyEvent, { ... })
})
nativeAddon.subscribe(dir, cb)
```
## Flag → Effect.Config migration
Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.26",
"version": "1.2.25",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -159,7 +159,7 @@ async function createToolContext(agent: Agent.Info) {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset })
throw new PermissionNext.DeniedError(ruleset)
}
}
},

View File

@@ -1,13 +0,0 @@
import { ServiceMap } from "effect"
import type { Project } from "@/project/project"
export declare namespace InstanceContext {
export interface Shape {
readonly directory: string
readonly project: Project.Info
}
}
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
"opencode/InstanceContext",
) {}

View File

@@ -1,12 +0,0 @@
const disposers = new Set<(directory: string) => Promise<void>>()
export function registerDisposer(disposer: (directory: string) => Promise<void>) {
disposers.add(disposer)
return () => {
disposers.delete(disposer)
}
}
export async function disposeInstance(directory: string) {
await Promise.allSettled([...disposers].map((disposer) => disposer(directory)))
}

View File

@@ -1,58 +0,0 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { registerDisposer } from "./instance-registry"
import { InstanceContext } from "./instance-context"
import { ProviderAuthService } from "@/provider/auth-service"
import { QuestionService } from "@/question/service"
import { PermissionService } from "@/permission/service"
import { FileWatcherService } from "@/file/watcher"
import { VcsService } from "@/project/vcs"
import { FileTimeService } from "@/file/time"
import { FormatService } from "@/format"
import { Instance } from "@/project/instance"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
| QuestionService
| PermissionService
| ProviderAuthService
| FileWatcherService
| VcsService
| FileTimeService
| FormatService
function lookup(directory: string) {
const project = Instance.project
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
return Layer.mergeAll(
Layer.fresh(QuestionService.layer),
Layer.fresh(PermissionService.layer),
Layer.fresh(ProviderAuthService.layer),
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer),
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
Layer.fresh(FormatService.layer),
).pipe(Layer.provide(ctx))
}
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
"opencode/Instances",
) {
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap)
}),
)
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
}
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
return Instances.use((map) => map.invalidate(directory))
}
}

View File

@@ -1,14 +1,5 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))

View File

@@ -1,88 +1,71 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { Flag } from "../flag/flag"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "file.time" })
export namespace FileTimeService {
export interface Service {
readonly read: (sessionID: string, file: string) => Effect.Effect<void>
readonly get: (sessionID: string, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: string, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
}
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
"@opencode/FileTime",
) {
static readonly layer = Layer.effect(
FileTimeService,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads: { [sessionID: string]: { [path: string]: Date | undefined } } = {}
const locks = new Map<string, Semaphore.Semaphore>()
function getLock(filepath: string) {
let lock = locks.get(filepath)
if (!lock) {
lock = Semaphore.makeUnsafe(1)
locks.set(filepath, lock)
}
return lock
}
return FileTimeService.of({
read: Effect.fn("FileTimeService.read")(function* (sessionID: string, file: string) {
log.info("read", { sessionID, file })
reads[sessionID] = reads[sessionID] || {}
reads[sessionID][file] = new Date()
}),
get: Effect.fn("FileTimeService.get")(function* (sessionID: string, file: string) {
return reads[sessionID]?.[file]
}),
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: string, filepath: string) {
if (disableCheck) return
const time = reads[sessionID]?.[filepath]
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const mtime = Filesystem.stat(filepath)?.mtime
if (mtime && mtime.getTime() > time.getTime() + 50) {
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
)
}
}),
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
const lock = getLock(filepath)
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
}),
})
}),
)
}
// Legacy facade — callers don't need to change
export namespace FileTime {
const log = Log.create({ service: "file.time" })
// Per-session read times plus per-file write locks.
// All tools that overwrite existing files should run their
// assert/read/write/update sequence inside withLock(filepath, ...)
// so concurrent writes to the same file are serialized.
export const state = Instance.state(() => {
const read: {
[sessionID: string]: {
[path: string]: Date | undefined
}
} = {}
const locks = new Map<string, Promise<void>>()
return {
read,
locks,
}
})
export function read(sessionID: string, file: string) {
// Fire-and-forget — callers never await this
runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
log.info("read", { sessionID, file })
const { read } = state()
read[sessionID] = read[sessionID] || {}
read[sessionID][file] = new Date()
}
export function get(sessionID: string, file: string) {
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
}
export async function assert(sessionID: string, filepath: string) {
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
return state().read[sessionID]?.[file]
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
const current = state()
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
let release: () => void = () => {}
const nextLock = new Promise<void>((resolve) => {
release = resolve
})
const chained = currentLock.then(() => nextLock)
current.locks.set(filepath, chained)
await currentLock
try {
return await fn()
} finally {
release()
if (current.locks.get(filepath) === chained) {
current.locks.delete(filepath)
}
}
}
export async function assert(sessionID: string, filepath: string) {
if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) {
return
}
const time = get(sessionID, filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const mtime = Filesystem.stat(filepath)?.mtime
// Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing
if (mtime && mtime.getTime() > time.getTime() + 50) {
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
)
}
}
}

View File

@@ -1,8 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Instance } from "@/project/instance"
import z from "zod"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
import { Config } from "../config/config"
@@ -10,140 +9,118 @@ import path from "path"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
import { Protected } from "./protected"
import { Flag } from "@/flag/flag"
import { Cause, Effect, Layer, ServiceMap } from "effect"
const SUBSCRIBE_TIMEOUT_MS = 10_000
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
export namespace FileWatcher {
export const Event = event
/** Whether the native @parcel/watcher binding is available on this platform. */
export const hasNativeBinding = () => !!watcher()
}
const log = Log.create({ service: "file.watcher" })
const init = Effect.fn("FileWatcherService.init")(function* () {})
export namespace FileWatcherService {
export interface Service {
readonly init: () => Effect.Effect<void>
}
}
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
"@opencode/FileWatcher",
) {
static readonly layer = Layer.effect(
FileWatcherService,
Effect.gen(function* () {
const instance = yield* InstanceContext
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER)
return FileWatcherService.of({ init })
log.info("init", { directory: instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
return FileWatcherService.of({ init })
}
const w = watcher()
if (!w) return FileWatcherService.of({ init })
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
// Clean up a subscription that resolves after timeout
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* Effect.promise(() => Config.get())
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()])
}
if (instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], {
cwd: instance.project.worktree,
}),
)
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
return FileWatcherService.of({ init })
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(FileWatcherService.of({ init }))
export const Event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
const state = Instance.state(
async () => {
log.info("init")
const cfg = await Config.get()
const backend = (() => {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
})()
if (!backend) {
log.error("watcher backend not supported", { platform: process.platform })
return {}
}
log.info("watcher backend", { platform: process.platform, backend })
const w = watcher()
if (!w) return {}
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
}
const subs: ParcelWatcher.AsyncSubscription[] = []
const cfgIgnores = cfg.watcher?.ignore ?? []
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()],
backend,
})
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
log.error("failed to subscribe to Instance.directory", { error: err })
pending.then((s) => s.unsubscribe()).catch(() => {})
return undefined
})
if (sub) subs.push(sub)
}
if (Instance.project.vcs === "git") {
const result = await git(["rev-parse", "--git-dir"], {
cwd: Instance.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
const pending = w.subscribe(vcsDir, subscribe, {
ignore: ignoreList,
backend,
})
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
log.error("failed to subscribe to vcsDir", { error: err })
pending.then((s) => s.unsubscribe()).catch(() => {})
return undefined
})
if (sub) subs.push(sub)
}
}
return { subs }
},
async (state) => {
if (!state.subs) return
await Promise.all(state.subs.map((sub) => sub?.unsubscribe()))
},
)
export function init() {
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) {
return
}
state()
}
}

View File

@@ -1,5 +1,3 @@
import { Config } from "effect"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
@@ -42,12 +40,8 @@ export namespace Flag {
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
Config.withDefault(false),
)
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean(
"OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER",
).pipe(Config.withDefault(false))
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
@@ -61,9 +55,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(
Config.withDefault(false),
)
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")

View File

@@ -9,13 +9,10 @@ import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "format" })
export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z
.object({
name: z.string(),
@@ -27,135 +24,117 @@ export namespace Format {
})
export type Status = z.infer<typeof Status>
export async function init() {
return runPromiseInstance(FormatService.use((s) => s.init()))
const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
}
}
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (result.command.length === 0) continue
result.enabled = async () => true
result.name = name
formatters[name] = result
}
return {
enabled,
formatters,
}
})
async function isEnabled(item: Formatter.Info) {
const s = await state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters)
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
export async function status() {
return runPromiseInstance(FormatService.use((s) => s.status()))
const s = await state()
const result: Status[] = []
for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled,
})
}
return result
}
}
export namespace FormatService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Format.Status[]>
}
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
static readonly layer = Layer.effect(
FormatService,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get())
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const result = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
}) as Formatter.Info
if (result.command.length === 0) continue
result.enabled = async () => true
result.name = name
formatters[name] = result
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
const unsubscribe = Bus.subscribe(
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
)
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
log.info("init")
const init = Effect.fn("FormatService.init")(function* () {})
const status = Effect.fn("FormatService.status")(function* () {
const result: Format.Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
})
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
return result
})
return FormatService.of({ init, status })
}),
)
}
})
}
}

View File

@@ -47,6 +47,11 @@ process.on("uncaughtException", (e) => {
})
})
// Ensure the process exits on terminal hangup (eg. closing the terminal tab).
// Without this, long-running commands like `serve` block on a never-resolving
// promise and survive as orphaned processes.
process.on("SIGHUP", () => process.exit())
let cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")

View File

@@ -0,0 +1,210 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID, MessageID } from "@/session/schema"
import z from "zod"
import { Log } from "../util/log"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
function toKeys(pattern: Info["pattern"], type: string): string[] {
return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern]
}
function covered(keys: string[], approved: Map<string, boolean>): boolean {
return keys.every((k) => {
for (const p of approved.keys()) {
if (Wildcard.match(k, p)) return true
}
return false
})
}
export const Info = z
.object({
id: PermissionID.zod,
type: z.string(),
pattern: z.union([z.string(), z.array(z.string())]).optional(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
callID: z.string().optional(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
}),
})
.meta({
ref: "Permission",
})
export type Info = z.infer<typeof Info>
interface PendingEntry {
info: Info
resolve: () => void
reject: (e: any) => void
}
export const Event = {
Updated: BusEvent.define("permission.updated", Info),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
permissionID: PermissionID.zod,
response: z.string(),
}),
),
}
const state = Instance.state(
() => ({
pending: new Map<SessionID, Map<PermissionID, PendingEntry>>(),
approved: new Map<SessionID, Map<string, boolean>>(),
}),
async (state) => {
for (const session of state.pending.values()) {
for (const item of session.values()) {
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
}
}
},
)
export function pending() {
return state().pending
}
export function list() {
const { pending } = state()
const result: Info[] = []
for (const session of pending.values()) {
for (const item of session.values()) {
result.push(item.info)
}
}
return result.sort((a, b) => a.id.localeCompare(b.id))
}
export async function ask(input: {
type: Info["type"]
message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
messageID: Info["messageID"]
metadata: Info["metadata"]
}) {
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
messageID: input.messageID,
toolCallID: input.callID,
pattern: input.pattern,
})
const approvedForSession = approved.get(input.sessionID)
const keys = toKeys(input.pattern, input.type)
if (approvedForSession && covered(keys, approvedForSession)) return
const info: Info = {
id: PermissionID.ascending(),
type: input.type,
pattern: input.pattern,
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),
},
}
switch (
await Plugin.trigger("permission.ask", info, {
status: "ask",
}).then((x) => x.status)
) {
case "deny":
throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata)
case "allow":
return
}
if (!pending.has(input.sessionID)) pending.set(input.sessionID, new Map())
return new Promise<void>((resolve, reject) => {
pending.get(input.sessionID)!.set(info.id, {
info,
resolve,
reject,
})
Bus.publish(Event.Updated, info)
})
}
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
log.info("response", input)
const { pending, approved } = state()
const session = pending.get(input.sessionID)
const match = session?.get(input.permissionID)
if (!session || !match) return
session.delete(input.permissionID)
if (session.size === 0) pending.delete(input.sessionID)
Bus.publish(Event.Replied, {
sessionID: input.sessionID,
permissionID: input.permissionID,
response: input.response,
})
if (input.response === "reject") {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
match.resolve()
if (input.response === "always") {
if (!approved.has(input.sessionID)) approved.set(input.sessionID, new Map())
const approvedSession = approved.get(input.sessionID)!
const approveKeys = toKeys(match.info.pattern, match.info.type)
for (const k of approveKeys) {
approvedSession.set(k, true)
}
const items = pending.get(input.sessionID)
if (!items) return
const toRespond: Info[] = []
for (const item of items.values()) {
const itemKeys = toKeys(item.info.pattern, item.info.type)
if (covered(itemKeys, approvedSession)) {
toRespond.push(item.info)
}
}
for (const item of toRespond) {
respond({
sessionID: item.sessionID,
permissionID: item.id,
response: input.response,
})
}
}
}
export class RejectedError extends Error {
constructor(
public readonly sessionID: SessionID,
public readonly permissionID: PermissionID,
public readonly toolCallID?: string,
public readonly metadata?: Record<string, any>,
public readonly reason?: string,
) {
super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
}
}
}

View File

@@ -1,11 +1,21 @@
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { SessionID, MessageID } from "@/session/schema"
import { PermissionID } from "./schema"
import { Instance } from "@/project/instance"
import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { ProjectID } from "@/project/schema"
import { Wildcard } from "@/util/wildcard"
import os from "os"
import * as S from "./service"
import z from "zod"
export namespace PermissionNext {
const log = Log.create({ service: "permission" })
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
@@ -14,22 +24,26 @@ export namespace PermissionNext {
return pattern
}
export const Action = S.Action
export type Action = S.Action
export const Rule = S.Rule
export type Rule = S.Rule
export const Ruleset = S.Ruleset
export type Ruleset = S.Ruleset
export const Request = S.Request
export type Request = S.Request
export const Reply = S.Reply
export type Reply = S.Reply
export const Approval = S.Approval
export const Event = S.Event
export const Service = S.PermissionService
export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
@@ -53,20 +67,178 @@ export namespace PermissionNext {
return rulesets.flat()
}
export const ask = fn(S.AskInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
)
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export const reply = fn(S.ReplyInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
)
export type Request = z.infer<typeof Request>
export async function list() {
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
interface PendingEntry {
info: Request
resolve: () => void
reject: (e: any) => void
}
const state = Instance.state(() => {
const projectID = Instance.project.id
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
)
const stored = row?.data ?? ([] as Ruleset)
return {
pending: new Map<PermissionID, PendingEntry>(),
approved: stored,
}
})
export const ask = fn(
Request.partial({ id: true }).extend({
ruleset: Ruleset,
}),
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny")
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? PermissionID.ascending()
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
s.pending.set(id, {
info,
resolve,
reject,
})
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
},
)
export const reply = fn(
z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
}),
async (input) => {
const s = await state()
const existing = s.pending.get(input.requestID)
if (!existing) return
s.pending.delete(input.requestID)
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError())
// Reject all other pending permissions for this session
const sessionID = existing.info.sessionID
for (const [id, pending] of s.pending) {
if (pending.info.sessionID === sessionID) {
s.pending.delete(id)
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "reject",
})
pending.reject(new RejectedError())
}
}
return
}
if (input.reply === "once") {
existing.resolve()
return
}
if (input.reply === "always") {
for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
existing.resolve()
const sessionID = existing.info.sessionID
for (const [id, pending] of s.pending) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (!ok) continue
s.pending.delete(id)
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "always",
})
pending.resolve()
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
return
}
},
)
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return S.evaluate(permission, pattern, ...rulesets)
const merged = merge(...rulesets)
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
@@ -75,10 +247,39 @@ export namespace PermissionNext {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}
/** User rejected without message - halts execution */
export class RejectedError extends Error {
constructor() {
super(`The user rejected permission to use this specific tool call.`)
}
}
/** User rejected with message - continues with guidance */
export class CorrectedError extends Error {
constructor(message: string) {
super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`)
}
}
/** Auto-rejected by config rule - halts execution */
export class DeniedError extends Error {
constructor(public readonly ruleset: Ruleset) {
super(
`The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
)
}
}
export async function list() {
const s = await state()
return Array.from(s.pending.values(), (x) => x.info)
}
}

View File

@@ -2,16 +2,16 @@ import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema"
import { withStatics } from "@/util/schema"
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
static make(id: string): PermissionID {
return this.makeUnsafe(id)
}
const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID"))
static ascending(id?: string): PermissionID {
return this.makeUnsafe(Identifier.ascending("permission", id))
}
export type PermissionID = typeof permissionIdSchema.Type
static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>
}
export const PermissionID = permissionIdSchema.pipe(
withStatics((schema: typeof permissionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)),
zod: Identifier.schema("permission").pipe(z.custom<PermissionID>()),
})),
)

View File

@@ -1,251 +0,0 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod"
import { PermissionID } from "./schema"
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export type PermissionError = DeniedError | RejectedError | CorrectedError
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export declare namespace PermissionService {
export interface Api {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
"@opencode/PermissionNext",
) {
static readonly layer = Layer.effect(
PermissionService,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
)
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
})
const list = Effect.fn("PermissionService.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return PermissionService.of({ ask, reply, list })
}),
)
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}

View File

@@ -309,24 +309,6 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
}
const parts = await sdk.session
.message({
path: {
id: incoming.message.sessionID,
messageID: incoming.message.id,
},
query: {
directory: input.directory,
},
throwOnError: true,
})
.catch(() => undefined)
if (parts?.data.parts?.some((part) => part.type === "compaction")) {
output.headers["x-initiator"] = "agent"
return
}
const session = await sdk.session
.get({
path: {

View File

@@ -1,28 +1,27 @@
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp"
import { FileWatcherService } from "../file/watcher"
import { FileWatcher } from "../file/watcher"
import { File } from "../file"
import { Project } from "./project"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
ShareNext.init()
await Format.init()
Format.init()
await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
FileWatcher.init()
File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Vcs.init()
Snapshot.init()
Truncate.init()

View File

@@ -1,3 +1,4 @@
import { Effect } from "effect"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
@@ -5,7 +6,7 @@ import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import { disposeInstance } from "@/effect/instance-registry"
import { InstanceState } from "@/util/instance-state"
interface Context {
directory: string
@@ -101,33 +102,23 @@ export const Instance = {
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
/**
* Captures the current instance ALS context and returns a wrapper that
* restores it when called. Use this for callbacks that fire outside the
* instance async context (native addons, event emitters, timers, etc.).
*/
bind<F extends (...args: any[]) => any>(fn: F): F {
const ctx = context.use()
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
return await next
},
async dispose() {
const directory = Instance.directory
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
emit(directory)
Log.Default.info("disposing instance", { directory: Instance.directory })
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
cache.delete(Instance.directory)
emit(Instance.directory)
},
async disposeAll() {
if (disposal.all) return disposal.all

View File

@@ -147,7 +147,7 @@ export namespace Project {
// generate id from root commit
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: sandbox,
})
.then(async (result) =>
@@ -170,8 +170,7 @@ export namespace Project {
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
// Write to common dir so the cache is shared across worktrees.
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
}
}

View File

@@ -1,12 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect"
const log = Log.create({ service: "vcs" })
@@ -28,57 +27,50 @@ export namespace Vcs {
ref: "VcsInfo",
})
export type Info = z.infer<typeof Info>
}
export namespace VcsService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
async function currentBranch() {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: Instance.worktree,
})
if (result.exitCode !== 0) return
const text = result.text().trim()
if (!text) return
return text
}
const state = Instance.state(
async () => {
if (Instance.project.vcs !== "git") {
return { branch: async () => undefined, unsubscribe: undefined }
}
let current = await currentBranch()
log.info("initialized", { branch: current })
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
if (evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch()
if (next !== current) {
log.info("branch changed", { from: current, to: next })
current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
})
return {
branch: async () => current,
unsubscribe,
}
},
async (state) => {
state.unsubscribe?.()
},
)
export async function init() {
return state()
}
export async function branch() {
return await state().then((s) => s.branch())
}
}
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
static readonly layer = Layer.effect(
VcsService,
Effect.gen(function* () {
const instance = yield* InstanceContext
let current: string | undefined
if (instance.project.vcs === "git") {
const currentBranch = async () => {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: instance.project.worktree,
})
if (result.exitCode !== 0) return undefined
const text = result.text().trim()
return text || undefined
}
current = yield* Effect.promise(() => currentBranch())
log.info("initialized", { branch: current })
const unsubscribe = Bus.subscribe(
FileWatcher.Event.Updated,
Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
const next = await currentBranch()
if (next !== current) {
log.info("branch changed", { from: current, to: next })
current = next
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
}
}),
)
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
}
return VcsService.of({
init: Effect.fn("VcsService.init")(function* () {}),
branch: Effect.fn("VcsService.branch")(function* () {
return current
}),
})
}),
)
}

View File

@@ -1,9 +1,12 @@
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { Instance } from "@/project/instance"
import { Plugin } from "../plugin"
import { filter, fromEntries, map, pipe } from "remeda"
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/service"
import { InstanceState } from "@/util/instance-state"
import { ProviderID } from "./schema"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { filter, fromEntries, map, pipe } from "remeda"
import z from "zod"
export const Method = z
@@ -51,13 +54,21 @@ export type ProviderAuthError =
export namespace ProviderAuthService {
export interface Service {
/** Get available auth methods for each provider (e.g. OAuth, API key). */
readonly methods: () => Effect.Effect<Record<string, Method[]>>
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
readonly callback: (input: {
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
/** Set an API key directly for a provider (no OAuth flow). */
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
}
}
@@ -68,29 +79,33 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
ProviderAuthService,
Effect.gen(function* () {
const auth = yield* Auth.AuthService
const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin")
return pipe(
await mod.Plugin.list(),
filter((x) => x.auth?.provider !== undefined),
map((x) => [x.auth!.provider, x.auth!] as const),
fromEntries(),
)
const state = yield* InstanceState.make({
lookup: () =>
Effect.promise(async () => {
const methods = pipe(
await Plugin.list(),
filter((x) => x.auth?.provider !== undefined),
map((x) => [x.auth!.provider, x.auth!] as const),
fromEntries(),
)
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
}),
})
const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
const x = yield* InstanceState.get(state)
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
})
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
providerID: ProviderID
method: number
}) {
const method = hooks[input.providerID].methods[input.method]
const s = yield* InstanceState.get(state)
const method = s.methods[input.providerID].methods[input.method]
if (method.type !== "oauth") return
const result = yield* Effect.promise(() => method.authorize())
pending.set(input.providerID, result)
s.pending.set(input.providerID, result)
return {
url: result.url,
method: result.method,
@@ -103,14 +118,17 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
method: number
code?: string
}) {
const match = pending.get(input.providerID)
const s = yield* InstanceState.get(state)
const match = s.pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code)
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) {
@@ -131,10 +149,18 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
}
})
const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) {
yield* auth.set(input.providerID, {
type: "api",
key: input.key,
})
})
return ProviderAuthService.of({
methods,
authorize,
callback,
api,
})
}),
)

View File

@@ -1,16 +1,25 @@
import { Effect, ManagedRuntime } from "effect"
import z from "zod"
import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn"
import * as S from "./auth-service"
import { ProviderID } from "./schema"
// Separate runtime: ProviderAuthService can't join the shared runtime because
// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import.
// AuthService is stateless file I/O so the duplicate instance is harmless.
const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer)
function runPromise<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
return rt.runPromise(S.ProviderAuthService.use(f))
}
export namespace ProviderAuth {
export const Method = S.Method
export type Method = S.Method
export async function methods() {
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
return runPromise((service) => service.methods())
}
export const Authorization = S.Authorization
@@ -21,8 +30,7 @@ export namespace ProviderAuth {
providerID: ProviderID.zod,
method: z.number(),
}),
async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
async (input): Promise<Authorization | undefined> => runPromise((service) => service.authorize(input)),
)
export const callback = fn(
@@ -31,7 +39,15 @@ export namespace ProviderAuth {
method: z.number(),
code: z.string().optional(),
}),
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
async (input) => runPromise((service) => service.callback(input)),
)
export const api = fn(
z.object({
providerID: ProviderID.zod,
key: z.string(),
}),
async (input) => runPromise((service) => service.api(input)),
)
export import OauthMissing = S.OauthMissing

View File

@@ -47,7 +47,7 @@ import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema"
const DEFAULT_CHUNK_TIMEOUT = 300_000
const DEFAULT_CHUNK_TIMEOUT = 120_000
export namespace Provider {
const log = Log.create({ service: "provider" })

View File

@@ -167,44 +167,40 @@ export namespace Pty {
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData(
Instance.bind((chunk) => {
session.cursor += chunk.length
ptyProcess.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (ws.data !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
)
ptyProcess.onExit(
Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode })
remove(id)
}),
)
if (ws.data !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
})
ptyProcess.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode })
remove(id)
})
Bus.publish(Event.Created, { info })
return info
}

View File

@@ -1,39 +1,167 @@
import { runPromiseInstance } from "@/effect/runtime"
import * as S from "./service"
import type { QuestionID } from "./schema"
import type { SessionID, MessageID } from "@/session/schema"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question {
export const Option = S.Option
export type Option = S.Option
export const Info = S.Info
export type Info = S.Info
export const Request = S.Request
export type Request = S.Request
export const Answer = S.Answer
export type Answer = S.Answer
export const Reply = S.Reply
export type Reply = S.Reply
export const Event = S.Event
export const RejectedError = S.RejectedError
const log = Log.create({ service: "question" })
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
ref: "QuestionOption",
})
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({
ref: "QuestionInfo",
})
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "QuestionRequest",
})
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({
ref: "QuestionAnswer",
})
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
interface PendingEntry {
info: Request
resolve: (answers: Answer[]) => void
reject: (e: any) => void
}
const state = Instance.state(async () => ({
pending: new Map<QuestionID, PendingEntry>(),
}))
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
const s = await state()
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
return new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
s.pending.set(id, {
info,
resolve,
reject,
})
Bus.publish(Event.Asked, info)
})
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
const s = await state()
const existing = s.pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
s.pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
existing.resolve(input.answers)
}
export async function reject(requestID: QuestionID): Promise<void> {
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
const s = await state()
const existing = s.pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
s.pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
existing.reject(new RejectedError())
}
export async function list(): Promise<Request[]> {
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
export class RejectedError extends Error {
constructor() {
super("The user dismissed this question")
}
}
export async function list() {
return state().then((x) => Array.from(x.pending.values(), (x) => x.info))
}
}

View File

@@ -2,16 +2,16 @@ import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema"
import { withStatics } from "@/util/schema"
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
static make(id: string): QuestionID {
return this.makeUnsafe(id)
}
const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
static ascending(id?: string): QuestionID {
return this.makeUnsafe(Identifier.ascending("question", id))
}
export type QuestionID = typeof questionIdSchema.Type
static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID>
}
export const QuestionID = questionIdSchema.pipe(
withStatics((schema: typeof questionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
})),
)

View File

@@ -1,172 +0,0 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
// --- Zod schemas (re-exported by facade) ---
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
// --- Effect service ---
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
export namespace QuestionService {
export interface Service {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
"@opencode/Question",
) {
static readonly layer = Layer.effect(
QuestionService,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("QuestionService.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("QuestionService.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return QuestionService.of({ ask, reply, reject, list })
}),
)
}

View File

@@ -14,8 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs, VcsService } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill"
import { Auth } from "../auth"
@@ -331,7 +330,7 @@ export namespace Server {
},
}),
async (c) => {
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
const branch = await Vcs.branch()
return c.json({
branch,
})

View File

@@ -1,38 +1,41 @@
import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
export const SessionID = Schema.String.pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)),
zod: Identifier.schema("session").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionID"))
export type SessionID = typeof sessionIdSchema.Type
export const SessionID = sessionIdSchema.pipe(
withStatics((schema: typeof sessionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)),
zod: Identifier.schema("session").pipe(z.custom<SessionID>()),
})),
)
export type SessionID = Schema.Schema.Type<typeof SessionID>
const messageIdSchema = Schema.String.pipe(Schema.brand("MessageID"))
export const MessageID = Schema.String.pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)),
zod: Identifier.schema("message").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
export type MessageID = typeof messageIdSchema.Type
export const MessageID = messageIdSchema.pipe(
withStatics((schema: typeof messageIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)),
zod: Identifier.schema("message").pipe(z.custom<MessageID>()),
})),
)
export type MessageID = Schema.Schema.Type<typeof MessageID>
const partIdSchema = Schema.String.pipe(Schema.brand("PartID"))
export const PartID = Schema.String.pipe(
Schema.brand("PartID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)),
zod: Identifier.schema("part").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
export type PartID = typeof partIdSchema.Type
export const PartID = partIdSchema.pipe(
withStatics((schema: typeof partIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)),
zod: Identifier.schema("part").pipe(z.custom<PartID>()),
})),
)
export type PartID = Schema.Schema.Type<typeof PartID>

View File

@@ -1,92 +0,0 @@
import { Schema, SchemaAST } from "effect"
import z from "zod"
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
}
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const out = body(ast)
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const next = desc ? out.describe(desc) : out
return ref ? next.meta({ ref }) : next
}
function body(ast: SchemaAST.AST): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast)
switch (ast._tag) {
case "String":
return z.string()
case "Number":
return z.number()
case "Boolean":
return z.boolean()
case "Null":
return z.null()
case "Undefined":
return z.undefined()
case "Any":
case "Unknown":
return z.unknown()
case "Never":
return z.never()
case "Literal":
return z.literal(ast.literal)
case "Union":
return union(ast)
case "Objects":
return object(ast)
case "Arrays":
return array(ast)
case "Declaration":
return decl(ast)
default:
return fail(ast)
}
}
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
if (items.length === 1) return walk(items[0]).optional()
if (items.length > 1)
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
return z.undefined().optional()
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
const items = ast.types.map(walk)
if (items.length === 1) return items[0]
if (items.length < 2) return fail(ast)
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
}
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z.record(z.string(), walk(sig.type))
}
if (ast.indexSignatures.length > 0) return fail(ast)
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
}
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
if (ast.elements.length > 0) return fail(ast)
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
if (ast.typeParameters.length !== 1) return fail(ast)
return walk(ast.typeParameters[0])
}
function fail(ast: SchemaAST.AST): never {
const ref = SchemaAST.resolveIdentifier(ast)
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
}

View File

@@ -0,0 +1,51 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance } from "@/project/instance"
const TypeId = Symbol.for("@opencode/InstanceState")
type Task = (key: string) => Effect.Effect<void>
const tasks = new Set<Task>()
export namespace InstanceState {
export interface State<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const make = <A, E = never, R = never>(input: {
lookup: (key: string) => Effect.Effect<A, E, R>
release?: (value: A, key: string) => Effect.Effect<void>
}): Effect.Effect<State<A, E, R>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: (key) =>
Effect.acquireRelease(input.lookup(key), (value) =>
input.release ? input.release(value, key) : Effect.void,
),
})
const task: Task = (key) => ScopedCache.invalidate(cache, key)
tasks.add(task)
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: State<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
export const has = <A, E, R>(self: State<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
export const invalidate = <A, E, R>(self: State<A, E, R>) => ScopedCache.invalidate(self.cache, Instance.directory)
export const dispose = (key: string) =>
Effect.all(
[...tasks].map((task) => task(key)),
{ concurrency: "unbounded" },
)
}

View File

@@ -15,39 +15,3 @@ export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M =>
Object.assign(schema, methods(schema))
declare const NewtypeBrand: unique symbol
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
/**
* Nominal wrapper for scalar types. The class itself is a valid schema —
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
*
* @example
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
* static make(id: string): QuestionID {
* return this.makeUnsafe(id)
* }
* }
*
* Schema.decodeEffect(QuestionID)(input)
*/
export function Newtype<Self>() {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
type Branded = NewtypeBrand<Tag>
abstract class Base {
declare readonly [NewtypeBrand]: Tag
static makeUnsafe(value: Schema.Schema.Type<S>): Self {
return value as unknown as Self
}
}
Object.setPrototypeOf(Base, schema)
return Base as unknown as (abstract new (_: never) => Branded) & {
readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self
} & Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe">
}
}

View File

@@ -1,5 +1,4 @@
import { describe, test, expect } from "bun:test"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
@@ -392,469 +391,4 @@ describe("file/index Filesystem patterns", () => {
})
})
})
describe("File.status()", () => {
test("detects modified file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const entry = result.find((f) => f.path === "file.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
expect(entry!.added).toBeGreaterThan(0)
expect(entry!.removed).toBeGreaterThan(0)
},
})
})
test("detects untracked file as added", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const entry = result.find((f) => f.path === "new.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("added")
expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4
expect(entry!.removed).toBe(0)
},
})
})
test("detects deleted file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "gone.txt")
await fs.writeFile(filepath, "content\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.rm(filepath)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
const entries = result.filter((f) => f.path === "gone.txt")
expect(entries.some((e) => e.status === "deleted")).toBe(true)
},
})
})
test("detects mixed changes", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet()
// Modify one, delete one, add one
await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8")
await fs.rm(path.join(tmp.path, "remove.txt"))
await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
},
})
})
test("returns empty for non-git project", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
expect(result).toEqual([])
},
})
})
test("returns empty for clean repo", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
expect(result).toEqual([])
},
})
})
test("parses binary numstat as 0", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "data.bin")
// Write content with null bytes so git treats it as binary
const binaryData = Buffer.alloc(256)
for (let i = 0; i < 256; i++) binaryData[i] = i
await fs.writeFile(filepath, binaryData)
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
// Modify the binary
const modified = Buffer.alloc(512)
for (let i = 0; i < 512; i++) modified[i] = i % 256
await fs.writeFile(filepath, modified)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const entry = result.find((f) => f.path === "data.bin")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
expect(entry!.added).toBe(0)
expect(entry!.removed).toBe(0)
},
})
})
})
describe("File.list()", () => {
test("returns files and directories with correct shape", async () => {
await using tmp = await tmpdir({ git: true })
await fs.mkdir(path.join(tmp.path, "subdir"))
await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
for (const node of nodes) {
expect(node).toHaveProperty("name")
expect(node).toHaveProperty("path")
expect(node).toHaveProperty("absolute")
expect(node).toHaveProperty("type")
expect(node).toHaveProperty("ignored")
expect(["file", "directory"]).toContain(node.type)
}
},
})
})
test("sorts directories before files, alphabetical within each", async () => {
await using tmp = await tmpdir({ git: true })
await fs.mkdir(path.join(tmp.path, "beta"))
await fs.mkdir(path.join(tmp.path, "alpha"))
await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const dirs = nodes.filter((n) => n.type === "directory")
const files = nodes.filter((n) => n.type === "file")
// Dirs come first
const firstFile = nodes.findIndex((n) => n.type === "file")
const lastDir = nodes.findLastIndex((n) => n.type === "directory")
if (lastDir >= 0 && firstFile >= 0) {
expect(lastDir).toBeLessThan(firstFile)
}
// Alphabetical within dirs
expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted())
// Alphabetical within files
expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted())
},
})
})
test("excludes .git and .DS_Store", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const names = nodes.map((n) => n.name)
expect(names).not.toContain(".git")
expect(names).not.toContain(".DS_Store")
expect(names).toContain("visible.txt")
},
})
})
test("marks gitignored files as ignored", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8")
await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
await fs.mkdir(path.join(tmp.path, "build"))
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const logNode = nodes.find((n) => n.name === "app.log")
const tsNode = nodes.find((n) => n.name === "main.ts")
const buildNode = nodes.find((n) => n.name === "build")
expect(logNode?.ignored).toBe(true)
expect(tsNode?.ignored).toBe(false)
expect(buildNode?.ignored).toBe(true)
},
})
})
test("lists subdirectory contents", async () => {
await using tmp = await tmpdir({ git: true })
await fs.mkdir(path.join(tmp.path, "sub"))
await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list("sub")
expect(nodes.length).toBe(2)
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
// Paths should be relative to project root (normalize for Windows)
expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
},
})
})
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.list("../outside")).rejects.toThrow("Access denied")
},
})
})
test("works without git", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
// Without git, ignored should be false for all
for (const node of nodes) {
expect(node.ignored).toBe(false)
}
},
})
})
})
describe("File.search()", () => {
async function setupSearchableRepo() {
const tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8")
await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8")
await fs.mkdir(path.join(tmp.path, "src"))
await fs.mkdir(path.join(tmp.path, ".hidden"))
await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8")
await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8")
return tmp
}
test("empty query returns files", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
// Give the background scan time to populate
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0)
},
})
})
test("empty query returns dirs sorted with hidden last", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "directory" })
expect(result.length).toBeGreaterThan(0)
// Find first hidden dir index
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1))
if (firstHidden >= 0 && lastVisible >= 0) {
expect(firstHidden).toBeGreaterThan(lastVisible)
}
},
})
})
test("fuzzy matches file names", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
})
test("type filter returns only files", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "file" })
// Files don't end with /
for (const f of result) {
expect(f.endsWith("/")).toBe(false)
}
},
})
})
test("type filter returns only directories", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "directory" })
// Directories end with /
for (const d of result) {
expect(d.endsWith("/")).toBe(true)
}
},
})
})
test("respects limit", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2)
},
})
})
test("query starting with dot prefers hidden files", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toContain(".hidden")
},
})
})
})
describe("File.read() - diff/patch", () => {
test("returns diff and patch for modified tracked file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "modified content\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("file.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("modified content")
expect(result.diff).toBeDefined()
expect(result.diff).toContain("original content")
expect(result.diff).toContain("modified content")
expect(result.patch).toBeDefined()
expect(result.patch!.hunks.length).toBeGreaterThan(0)
},
})
})
test("returns diff for staged changes", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "staged.txt")
await fs.writeFile(filepath, "before\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(filepath, "after\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("staged.txt")
expect(result.diff).toBeDefined()
expect(result.patch).toBeDefined()
},
})
})
test("returns no diff for unmodified file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "clean.txt")
await fs.writeFile(filepath, "unchanged\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("clean.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("unchanged")
expect(result.diff).toBeUndefined()
expect(result.patch).toBeUndefined()
},
})
})
})
})

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, afterEach } from "bun:test"
import { describe, test, expect, beforeEach } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { FileTime } from "../../src/file/time"
@@ -6,8 +6,6 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
afterEach(() => Instance.disposeAll())
describe("file/time", () => {
const sessionID = "test-session-123"
@@ -20,13 +18,12 @@ describe("file/time", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await FileTime.get(sessionID, filepath)
const before = FileTime.get(sessionID, filepath)
expect(before).toBeUndefined()
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const after = await FileTime.get(sessionID, filepath)
const after = FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
},
@@ -43,10 +40,9 @@ describe("file/time", () => {
fn: async () => {
FileTime.read("session1", filepath)
FileTime.read("session2", filepath)
await Bun.sleep(10)
const time1 = await FileTime.get("session1", filepath)
const time2 = await FileTime.get("session2", filepath)
const time1 = FileTime.get("session1", filepath)
const time2 = FileTime.get("session2", filepath)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
@@ -63,16 +59,14 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const first = await FileTime.get(sessionID, filepath)
const first = FileTime.get(sessionID, filepath)!
await Bun.sleep(10)
await new Promise((resolve) => setTimeout(resolve, 10))
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const second = await FileTime.get(sessionID, filepath)
const second = FileTime.get(sessionID, filepath)!
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
},
})
})
@@ -88,7 +82,8 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
// Should not throw
await FileTime.assert(sessionID, filepath)
},
})
@@ -116,8 +111,13 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(100)
// Wait to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Modify file after reading
await fs.writeFile(filepath, "modified content", "utf-8")
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
},
})
@@ -132,7 +132,7 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(100)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8")
let error: Error | undefined
@@ -147,6 +147,28 @@ describe("file/time", () => {
},
})
})
test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Flag } = await import("../../src/flag/flag")
const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
try {
// Should not throw even though file wasn't read
await FileTime.assert(sessionID, filepath)
} finally {
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
}
},
})
})
})
describe("withLock()", () => {
@@ -193,7 +215,7 @@ describe("file/time", () => {
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
await Bun.sleep(50)
await new Promise((resolve) => setTimeout(resolve, 10))
order.push(2)
})
@@ -203,7 +225,12 @@ describe("file/time", () => {
})
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
// Operations should be serialized
expect(order).toContain(1)
expect(order).toContain(2)
expect(order).toContain(3)
expect(order).toContain(4)
},
})
})
@@ -221,8 +248,8 @@ describe("file/time", () => {
const op1 = FileTime.withLock(filepath1, async () => {
started1 = true
await Bun.sleep(50)
expect(started2).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(started2).toBe(true) // op2 should have started while op1 is running
})
const op2 = FileTime.withLock(filepath2, async () => {
@@ -230,6 +257,7 @@ describe("file/time", () => {
})
await Promise.all([op1, op2])
expect(started1).toBe(true)
expect(started2).toBe(true)
},
@@ -249,6 +277,7 @@ describe("file/time", () => {
}),
).rejects.toThrow("Test error")
// Lock should be released, subsequent operations should work
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
@@ -257,6 +286,31 @@ describe("file/time", () => {
},
})
})
test("deadlocks on nested locks (expected behavior)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Nested locks on same file cause deadlock - this is expected
// The outer lock waits for inner to complete, but inner waits for outer to release
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Deadlock detected")), 100),
)
const nestedLock = FileTime.withLock(filepath, async () => {
return FileTime.withLock(filepath, async () => {
return "inner"
})
})
// Should timeout due to deadlock
await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
},
})
})
})
describe("stat() Filesystem.stat pattern", () => {
@@ -269,12 +323,12 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
// FileTime.assert uses this stat internally
await FileTime.assert(sessionID, filepath)
},
})
@@ -289,11 +343,11 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const originalStat = Filesystem.stat(filepath)
await Bun.sleep(100)
// Wait and modify
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8")
const newStat = Filesystem.stat(filepath)

View File

@@ -1,236 +0,0 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcherService. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return withServices(
directory,
FileWatcherService.layer,
async (rt) => {
await rt.runPromise(FileWatcherService.use((s) => s.init()))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
{ provide: [watcherConfigLayer] },
)
}
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false
function on(evt: BusUpdate) {
if (done) return
if (evt.directory !== directory) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
if (!check(evt.payload.properties)) return
hit(evt.payload.properties)
}
function cleanup() {
if (done) return
done = true
GlobalBus.off("event", on)
}
GlobalBus.on("event", on)
return cleanup
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
return Effect.callback<WatcherEvent>((resume) => {
const cleanup = listen(directory, check, (evt) => {
cleanup()
resume(Effect.succeed(evt))
})
return Effect.sync(cleanup)
}).pipe(Effect.timeout("5 seconds"))
}
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.acquireUseRelease(
wait(directory, check).pipe(Effect.forkChild({ startImmediately: true })),
(fiber) =>
Effect.gen(function* () {
yield* trigger
return yield* Fiber.join(fiber)
}),
Fiber.interrupt,
)
}
/** Effect that asserts no matching event arrives within `ms`. */
function noUpdate<E>(
directory: string,
check: (evt: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
ms = 500,
) {
return Effect.gen(function* () {
const deferred = yield* Deferred.make<WatcherEvent>()
yield* Effect.acquireUseRelease(
Effect.sync(() =>
listen(directory, check, (evt) => {
Effect.runSync(Deferred.succeed(deferred, evt))
}),
),
() =>
Effect.gen(function* () {
yield* trigger
expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
}),
(cleanup) => Effect.sync(cleanup),
)
})
}
function ready(directory: string) {
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
const head = path.join(directory, ".git", "HEAD")
return Effect.gen(function* () {
yield* nextUpdate(
directory,
(evt) => evt.file === file && evt.event === "add",
Effect.promise(() => fs.writeFile(file, "ready")),
).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
const git = yield* Effect.promise(() =>
fs
.stat(head)
.then(() => true)
.catch(() => false),
)
if (!git) return
const branch = `watch-${Math.random().toString(36).slice(2)}`
const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
yield* nextUpdate(
directory,
(evt) => evt.file === head && evt.event !== "unlink",
Effect.promise(async () => {
await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
}),
).pipe(Effect.asVoid)
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describeWatcher("FileWatcherService", () => {
afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => {
await using tmp = await tmpdir({ git: true })
const file = path.join(tmp.path, "watch.txt")
const dir = tmp.path
const cases = [
{ event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
{ event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
{ event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
]
await withWatcher(
dir,
Effect.forEach(cases, ({ event, trigger }) =>
nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
),
),
)
})
test("watches non-git roots", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "plain.txt")
const dir = tmp.path
await withWatcher(
dir,
nextUpdate(
dir,
(e) => e.file === file && e.event === "add",
Effect.promise(() => fs.writeFile(file, "plain")),
).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
)
})
test("cleanup stops publishing events", async () => {
await using tmp = await tmpdir({ git: true })
const file = path.join(tmp.path, "after-dispose.txt")
// Start and immediately stop the watcher (withWatcher disposes on exit)
await withWatcher(tmp.path, Effect.void)
// Now write a file — no watcher should be listening
await Effect.runPromise(
noUpdate(
tmp.path,
(e) => e.file === file,
Effect.promise(() => fs.writeFile(file, "gone")),
),
)
})
test("ignores .git/index changes", async () => {
await using tmp = await tmpdir({ git: true })
const gitIndex = path.join(tmp.path, ".git", "index")
const edit = path.join(tmp.path, "tracked.txt")
await withWatcher(
tmp.path,
noUpdate(
tmp.path,
(e) => e.file === gitIndex,
Effect.promise(async () => {
await fs.writeFile(edit, "a")
await $`git add .`.cwd(tmp.path).quiet().nothrow()
}),
),
)
})
test("publishes .git/HEAD events", async () => {
await using tmp = await tmpdir({ git: true })
const head = path.join(tmp.path, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
await $`git branch ${branch}`.cwd(tmp.path).quiet()
await withWatcher(
tmp.path,
nextUpdate(
tmp.path,
(evt) => evt.file === head && evt.event !== "unlink",
Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
).pipe(
Effect.tap((evt) =>
Effect.sync(() => {
expect(evt.file).toBe(head)
expect(["add", "change"]).toContain(evt.event)
}),
),
),
)
})
})

View File

@@ -1,47 +0,0 @@
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
import { InstanceContext } from "../../src/effect/instance-context"
import { Instance } from "../../src/project/instance"
/** ConfigProvider that enables the experimental file watcher. */
export const watcherConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}),
)
/**
* Boot an Instance with the given service layers and run `body` with
* the ManagedRuntime. Cleanup is automatic — the runtime is disposed
* and Instance context is torn down when `body` completes.
*
* Layers may depend on InstanceContext (provided automatically).
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
*/
export function withServices<S>(
directory: string,
layer: Layer.Layer<S, any, InstanceContext>,
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
options?: { provide?: Layer.Layer<never>[] },
) {
return Instance.provide({
directory,
fn: async () => {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
)
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
if (options?.provide) {
for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any
}
}
const rt = ManagedRuntime.make(resolved)
try {
await body(rt)
} finally {
await rt.dispose()
}
},
})
}

View File

@@ -1,64 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
import { FormatService } from "../../src/format"
import { Instance } from "../../src/project/instance"
describe("FormatService", () => {
afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0)
for (const s of statuses) {
expect(typeof s.name).toBe("string")
expect(Array.isArray(s.extensions)).toBe(true)
expect(typeof s.enabled).toBe("boolean")
}
const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
})
})
test("status() returns empty list when formatter is disabled", async () => {
await using tmp = await tmpdir({
config: { formatter: false },
})
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(statuses).toEqual([])
})
})
test("status() excludes formatters marked as disabled in config", async () => {
await using tmp = await tmpdir({
config: {
formatter: {
gofmt: { disabled: true },
},
},
})
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeUndefined()
})
})
test("init() completes without error", async () => {
await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => {
await rt.runPromise(FormatService.use((s) => s.init()))
})
})
})

View File

@@ -1,38 +1,10 @@
import { afterEach, test, expect } from "bun:test"
import { test, expect } from "bun:test"
import os from "os"
import { Effect } from "effect"
import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission/next"
import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { MessageID, SessionID } from "../../src/session/schema"
afterEach(async () => {
await Instance.disposeAll()
})
async function rejectAll(message?: string) {
for (const req of await PermissionNext.list()) {
await PermissionNext.reply({
requestID: req.id,
reply: "reject",
message,
})
}
}
async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) {
const list = await PermissionNext.list()
if (list.length === count) return list
await Bun.sleep(0)
}
return PermissionNext.list()
}
import { SessionID } from "../../src/session/schema"
// fromConfig tests
@@ -539,84 +511,6 @@ test("ask - returns pending promise when action is ask", async () => {
// Promise should be pending, not resolved
expect(promise).toBeInstanceOf(Promise)
// Don't await - just verify it returns a promise
await rejectAll()
await promise.catch(() => {})
},
})
})
test("ask - adds request to pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
})
const list = await PermissionNext.list()
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
})
await rejectAll()
await ask.catch(() => {})
},
})
})
test("ask - publishes asked event", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
let seen: PermissionNext.Request | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
seen = event.properties
})
const ask = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
})
expect(await PermissionNext.list()).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
})
unsub()
await rejectAll()
await ask.catch(() => {})
},
})
})
@@ -638,8 +532,6 @@ test("reply - once resolves the pending ask", async () => {
ruleset: [],
})
await waitForPending(1)
await PermissionNext.reply({
requestID: PermissionID.make("per_test1"),
reply: "once",
@@ -665,8 +557,6 @@ test("reply - reject throws RejectedError", async () => {
ruleset: [],
})
await waitForPending(1)
await PermissionNext.reply({
requestID: PermissionID.make("per_test2"),
reply: "reject",
@@ -677,36 +567,6 @@ test("reply - reject throws RejectedError", async () => {
})
})
test("reply - reject with message throws CorrectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(1)
await PermissionNext.reply({
requestID: PermissionID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
const err = await ask.catch((err) => err)
expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
expect(err.message).toContain("Use a safer command")
},
})
})
test("reply - always persists approval and resolves", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -722,8 +582,6 @@ test("reply - always persists approval and resolves", async () => {
ruleset: [],
})
await waitForPending(1)
await PermissionNext.reply({
requestID: PermissionID.make("per_test3"),
reply: "always",
@@ -775,8 +633,6 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [],
})
await waitForPending(2)
// Catch rejections before they become unhandled
const result1 = askPromise1.catch((e) => e)
const result2 = askPromise2.catch((e) => e)
@@ -794,144 +650,6 @@ test("reply - reject cancels all pending for same session", async () => {
})
})
test("reply - always resolves matching pending requests in same session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = PermissionNext.ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
})
const b = PermissionNext.ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(2)
await PermissionNext.reply({
requestID: PermissionID.make("per_test5a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined()
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
test("reply - always keeps other session pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = PermissionNext.ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
})
const b = PermissionNext.ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(2)
await PermissionNext.reply({
requestID: PermissionID.make("per_test6a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
await rejectAll()
await b.catch(() => {})
},
})
})
test("reply - publishes replied event", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(1)
let seen:
| {
sessionID: SessionID
requestID: PermissionID
reply: PermissionNext.Reply
}
| undefined
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
seen = event.properties
})
await PermissionNext.reply({
requestID: PermissionID.make("per_test7"),
reply: "once",
})
await expect(ask).resolves.toBeUndefined()
expect(seen).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
reply: "once",
})
unsub()
},
})
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await PermissionNext.reply({
requestID: PermissionID.make("per_unknown"),
reply: "once",
})
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
test("ask - checks all patterns and stops on first deny", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -971,62 +689,3 @@ test("ask - allows all patterns when all match allow rules", async () => {
},
})
})
test("ask - should deny even when an earlier pattern is ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const err = await PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "echo *", action: "ask" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}).then(
() => undefined,
(err) => err,
)
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
test("ask - abort should clear pending request", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
S.PermissionService.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}),
).pipe(Effect.provide(Instances.get(Instance.directory))),
{ signal: ctl.signal },
)
await waitForPending(1)
ctl.abort()
await ask.catch(() => {})
try {
expect(await PermissionNext.list()).toHaveLength(0)
} finally {
await rejectAll()
}
},
})
})

View File

@@ -23,7 +23,7 @@ mock.module("../../src/util/git", () => ({
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("HEAD")
cmd.includes("--all")
) {
return Promise.resolve({
exitCode: 128,
@@ -172,52 +172,6 @@ describe("Project.fromDirectory with worktrees", () => {
}
})
test("worktree should share project ID with main repo", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project: main } = await p.fromDirectory(tmp.path)
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await p.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Filesystem.exists(cache)
expect(exists).toBe(true)
} finally {
await $`git worktree remove ${worktreePath}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
}
})
test("separate clones of the same repo should share project ID", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
// Create a bare remote, push, then clone into a second directory
const bare = tmp.path + "-bare"
const clone = tmp.path + "-clone"
try {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await p.fromDirectory(tmp.path)
const { project: b } = await p.fromDirectory(clone)
expect(b.id).toBe(a.id)
} finally {
await $`rm -rf ${bare} ${clone}`.quiet().nothrow()
}
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })

View File

@@ -1,117 +0,0 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
import { Vcs, VcsService } from "../../src/project/vcs"
// Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function withVcs(
directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
) {
return withServices(
directory,
Layer.merge(FileWatcherService.layer, VcsService.layer),
async (rt) => {
await rt.runPromise(FileWatcherService.use((s) => s.init()))
await rt.runPromise(VcsService.use((s) => s.init()))
await Bun.sleep(200)
await body(rt)
},
{ provide: [watcherConfigLayer] },
)
}
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
function nextBranchUpdate(directory: string, timeout = 5000) {
return new Promise<string | undefined>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", on)
reject(new Error("timed out waiting for BranchUpdated event"))
}, timeout)
function on(evt: BranchEvent) {
if (evt.directory !== directory) return
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties.branch)
}
GlobalBus.on("event", on)
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describeVcs("Vcs", () => {
afterEach(() => Instance.disposeAll())
test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
})
test("branch() returns undefined for non-git directories", async () => {
await using tmp = await tmpdir()
await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(branch).toBeUndefined()
})
})
test("publishes BranchUpdated when .git/HEAD changes", async () => {
await using tmp = await tmpdir({ git: true })
const branch = `test-${Math.random().toString(36).slice(2)}`
await $`git branch ${branch}`.cwd(tmp.path).quiet()
await withVcs(tmp.path, async () => {
const pending = nextBranchUpdate(tmp.path)
const head = path.join(tmp.path, ".git", "HEAD")
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
const updated = await pending
expect(updated).toBe(branch)
})
})
test("branch() reflects the new branch after HEAD change", async () => {
await using tmp = await tmpdir({ git: true })
const branch = `test-${Math.random().toString(36).slice(2)}`
await $`git branch ${branch}`.cwd(tmp.path).quiet()
await withVcs(tmp.path, async (rt) => {
const pending = nextBranchUpdate(tmp.path)
const head = path.join(tmp.path, ".git", "HEAD")
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending
const current = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(current).toBe(branch)
})
})
})

View File

@@ -0,0 +1,20 @@
import { afterEach, expect, test } from "bun:test"
import { Auth } from "../../src/auth"
import { ProviderAuth } from "../../src/provider/auth"
import { ProviderID } from "../../src/provider/schema"
afterEach(async () => {
await Auth.remove("test-provider-auth")
})
test("ProviderAuth.api persists auth via AuthService", async () => {
await ProviderAuth.api({
providerID: ProviderID.make("test-provider-auth"),
key: "sk-test",
})
expect(await Auth.get("test-provider-auth")).toEqual({
type: "api",
key: "sk-test",
})
})

View File

@@ -6,7 +6,7 @@ import type { PtyID } from "../../src/pty/schema"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
const wait = async (fn: () => boolean, ms = 5000) => {
const wait = async (fn: () => boolean, ms = 2000) => {
const end = Date.now() + ms
while (Date.now() < end) {
if (fn()) return
@@ -20,7 +20,7 @@ const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>,
}
describe("pty", () => {
test("publishes created, exited, deleted in order for a short-lived process", async () => {
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
@@ -37,11 +37,7 @@ describe("pty", () => {
let id: PtyID | undefined
try {
const info = await Pty.create({
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"],
title: "sleep",
})
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
id = info.id
await wait(() => pick(log, id!).includes("exited"))

View File

@@ -1,22 +1,10 @@
import { afterEach, test, expect } from "bun:test"
import { test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
afterEach(async () => {
await Instance.disposeAll()
})
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() {
const pending = await Question.list()
for (const req of pending) {
await Question.reject(req.id)
}
}
test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -36,8 +24,6 @@ test("ask - returns pending promise", async () => {
],
})
expect(promise).toBeInstanceOf(Promise)
await rejectAll()
await promise.catch(() => {})
},
})
})
@@ -58,7 +44,7 @@ test("ask - adds to pending list", async () => {
},
]
const askPromise = Question.ask({
Question.ask({
sessionID: SessionID.make("ses_test"),
questions,
})
@@ -66,8 +52,6 @@ test("ask - adds to pending list", async () => {
const pending = await Question.list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
await rejectAll()
await askPromise.catch(() => {})
},
})
})
@@ -114,7 +98,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
Question.ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -135,7 +119,6 @@ test("reply - removes from pending list", async () => {
requestID: pending[0].id,
answers: [["Option 1"]],
})
await askPromise
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
@@ -279,7 +262,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const p1 = Question.ask({
Question.ask({
sessionID: SessionID.make("ses_test1"),
questions: [
{
@@ -290,7 +273,7 @@ test("list - returns all pending requests", async () => {
],
})
const p2 = Question.ask({
Question.ask({
sessionID: SessionID.make("ses_test2"),
questions: [
{
@@ -303,9 +286,6 @@ test("list - returns all pending requests", async () => {
const pending = await Question.list()
expect(pending.length).toBe(2)
await rejectAll()
p1.catch(() => {})
p2.catch(() => {})
},
})
})

View File

@@ -183,7 +183,7 @@ describe("tool.read env file permissions", () => {
askedForEnv = true
}
if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset: agent.permission })
throw new PermissionNext.DeniedError(agent.permission)
}
}
},

View File

@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { zod } from "../../src/util/effect-zod"
describe("util.effect-zod", () => {
test("converts class schemas for route dto shapes", () => {
class Method extends Schema.Class<Method>("ProviderAuthMethod")({
type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
label: Schema.String,
}) {}
const out = zod(Method)
expect(out.meta()?.ref).toBe("ProviderAuthMethod")
expect(
out.parse({
type: "oauth",
label: "OAuth",
}),
).toEqual({
type: "oauth",
label: "OAuth",
})
})
test("converts structs with optional fields, arrays, and records", () => {
const out = zod(
Schema.Struct({
foo: Schema.optional(Schema.String),
bar: Schema.Array(Schema.Number),
baz: Schema.Record(Schema.String, Schema.Boolean),
}),
)
expect(
out.parse({
bar: [1, 2],
baz: { ok: true },
}),
).toEqual({
bar: [1, 2],
baz: { ok: true },
})
expect(
out.parse({
foo: "hi",
bar: [1],
baz: { ok: false },
}),
).toEqual({
foo: "hi",
bar: [1],
baz: { ok: false },
})
})
test("throws for unsupported tuple schemas", () => {
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
})
})

View File

@@ -0,0 +1,139 @@
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { InstanceState } from "../../src/util/instance-state"
import { tmpdir } from "../fixture/fixture"
async function access<A, E>(state: InstanceState.State<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
})
}
afterEach(async () => {
await Instance.disposeAll()
})
test("InstanceState caches values for the same instance", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make({
lookup: () => Effect.sync(() => ({ n: ++n })),
})
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("InstanceState isolates values by directory", async () => {
await using a = await tmpdir()
await using b = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make({
lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })),
})
const x = yield* Effect.promise(() => access(state, a.path))
const y = yield* Effect.promise(() => access(state, b.path))
const z = yield* Effect.promise(() => access(state, a.path))
expect(x).toBe(z)
expect(x).not.toBe(y)
expect(n).toBe(2)
}),
),
)
})
test("InstanceState is disposed on instance reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make({
lookup: () => Effect.sync(() => ({ n: ++n })),
release: (value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
})
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
),
)
})
test("InstanceState is disposed on disposeAll", async () => {
await using a = await tmpdir()
await using b = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make({
lookup: (dir) => Effect.sync(() => ({ dir })),
release: (value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
})
yield* Effect.promise(() => access(state, a.path))
yield* Effect.promise(() => access(state, b.path))
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([a.path, b.path].sort())
}),
),
)
})
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make({
lookup: () =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
})
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -54,106 +54,6 @@ export type EventServerInstanceDisposed = {
}
}
export type PermissionRequest = {
id: string
sessionID: string
permission: string
patterns: Array<string>
metadata: {
[key: string]: unknown
}
always: Array<string>
tool?: {
messageID: string
callID: string
}
}
export type EventPermissionAsked = {
type: "permission.asked"
properties: PermissionRequest
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
requestID: string
reply: "once" | "always" | "reject"
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
@@ -649,6 +549,35 @@ export type EventMessagePartRemoved = {
}
}
export type PermissionRequest = {
id: string
sessionID: string
permission: string
patterns: Array<string>
metadata: {
[key: string]: unknown
}
always: Array<string>
tool?: {
messageID: string
callID: string
}
}
export type EventPermissionAsked = {
type: "permission.asked"
properties: PermissionRequest
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
requestID: string
reply: "once" | "always" | "reject"
}
}
export type SessionStatus =
| {
type: "idle"
@@ -678,6 +607,77 @@ export type EventSessionIdle = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
@@ -962,11 +962,6 @@ export type Event =
| EventInstallationUpdateAvailable
| EventProjectUpdated
| EventServerInstanceDisposed
| EventPermissionAsked
| EventPermissionReplied
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventServerConnected
| EventGlobalDisposed
| EventLspClientDiagnostics
@@ -977,8 +972,13 @@ export type Event =
| EventMessagePartUpdated
| EventMessagePartDelta
| EventMessagePartRemoved
| EventPermissionAsked
| EventPermissionReplied
| EventSessionStatus
| EventSessionIdle
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventSessionCompacted
| EventFileWatcherUpdated
| EventTodoUpdated

View File

@@ -7062,246 +7062,6 @@
},
"required": ["type", "properties"]
},
"PermissionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"permission": {
"type": "string"
},
"patterns": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"always": {
"type": "array",
"items": {
"type": "string"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
},
"Event.permission.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.asked"
},
"properties": {
"$ref": "#/components/schemas/PermissionRequest"
}
},
"required": ["type", "properties"]
},
"Event.permission.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^per.*"
},
"reply": {
"type": "string",
"enum": ["once", "always", "reject"]
}
},
"required": ["sessionID", "requestID", "reply"]
}
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string"
},
"description": {
"description": "Explanation of choice",
"type": "string"
}
},
"required": ["label", "description"]
},
"QuestionInfo": {
"type": "object",
"properties": {
"question": {
"description": "Complete question",
"type": "string"
},
"header": {
"description": "Very short label (max 30 chars)",
"type": "string"
},
"options": {
"description": "Available choices",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionOption"
}
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
},
"custom": {
"description": "Allow typing a custom answer (default: true)",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
},
"QuestionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^que.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"questions": {
"description": "Questions to ask",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionInfo"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "questions"]
},
"Event.question.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.asked"
},
"properties": {
"$ref": "#/components/schemas/QuestionRequest"
}
},
"required": ["type", "properties"]
},
"QuestionAnswer": {
"type": "array",
"items": {
"type": "string"
}
},
"Event.question.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
}
},
"required": ["type", "properties"]
},
"Event.question.rejected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.rejected"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
}
},
"required": ["sessionID", "requestID"]
}
},
"required": ["type", "properties"]
},
"Event.server.connected": {
"type": "object",
"properties": {
@@ -8760,6 +8520,96 @@
},
"required": ["type", "properties"]
},
"PermissionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"permission": {
"type": "string"
},
"patterns": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"always": {
"type": "array",
"items": {
"type": "string"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
},
"Event.permission.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.asked"
},
"properties": {
"$ref": "#/components/schemas/PermissionRequest"
}
},
"required": ["type", "properties"]
},
"Event.permission.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^per.*"
},
"reply": {
"type": "string",
"enum": ["once", "always", "reject"]
}
},
"required": ["sessionID", "requestID", "reply"]
}
},
"required": ["type", "properties"]
},
"SessionStatus": {
"anyOf": [
{
@@ -8846,6 +8696,156 @@
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string"
},
"description": {
"description": "Explanation of choice",
"type": "string"
}
},
"required": ["label", "description"]
},
"QuestionInfo": {
"type": "object",
"properties": {
"question": {
"description": "Complete question",
"type": "string"
},
"header": {
"description": "Very short label (max 30 chars)",
"type": "string"
},
"options": {
"description": "Available choices",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionOption"
}
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
},
"custom": {
"description": "Allow typing a custom answer (default: true)",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
},
"QuestionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^que.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"questions": {
"description": "Questions to ask",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionInfo"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "questions"]
},
"Event.question.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.asked"
},
"properties": {
"$ref": "#/components/schemas/QuestionRequest"
}
},
"required": ["type", "properties"]
},
"QuestionAnswer": {
"type": "array",
"items": {
"type": "string"
}
},
"Event.question.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
}
},
"required": ["type", "properties"]
},
"Event.question.rejected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.rejected"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
}
},
"required": ["sessionID", "requestID"]
}
},
"required": ["type", "properties"]
},
"Event.session.compacted": {
"type": "object",
"properties": {
@@ -9611,21 +9611,6 @@
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.server.connected"
},
@@ -9656,12 +9641,27 @@
{
"$ref": "#/components/schemas/Event.message.part.removed"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.26",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -115,7 +115,7 @@
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 8px;
gap: 4px;
/* text-12-medium */
font-family: var(--font-family-sans);
@@ -135,7 +135,7 @@
}
font-size: var(--font-size-small);
gap: 8px;
gap: 6px;
/* text-12-medium */
font-family: var(--font-family-sans);
@@ -153,7 +153,7 @@
padding: 0 12px 0 8px;
}
gap: 8px;
gap: 4px;
/* text-14-medium */
font-family: var(--font-family-sans);

View File

@@ -82,7 +82,6 @@ const icons = {
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
shield: `<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,

View File

@@ -19,7 +19,6 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "
children?: (item: T | undefined) => JSX.Element
triggerStyle?: JSX.CSSProperties
triggerVariant?: "settings"
triggerProps?: Record<string, string | number | boolean | undefined>
}
export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
@@ -39,7 +38,6 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
"children",
"triggerStyle",
"triggerVariant",
"triggerProps",
])
const state = {
@@ -133,7 +131,6 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
}}
>
<Kobalte.Trigger
{...local.triggerProps}
disabled={props.disabled}
data-slot="select-select-trigger"
as={Button}

View File

@@ -125,10 +125,7 @@
[data-slot="session-review-filename"] {
color: var(--text-strong);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
[data-slot="session-review-view-button"] {

View File

@@ -190,12 +190,9 @@
}
[data-slot="session-turn-diff-filename"] {
min-width: 0;
flex-shrink: 0;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="session-turn-diff-meta"] {

View File

@@ -428,11 +428,7 @@ async function highlightCodeBlocks(html: string): Promise<string> {
const matches = [...html.matchAll(codeBlockRegex)]
if (matches.length === 0) return html
const highlighter = await getSharedHighlighter({
themes: ["OpenCode"],
langs: [],
preferredHighlighter: "shiki-wasm",
})
const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] })
let result = html
for (const match of matches) {
@@ -483,11 +479,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
}),
markedShiki({
async highlight(code, lang) {
const highlighter = await getSharedHighlighter({
themes: ["OpenCode"],
langs: [],
preferredHighlighter: "shiki-wasm",
})
const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] })
if (!(lang in bundledLanguages)) {
lang = "text"
}

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