Merge remote-tracking branch 'origin/dev' into feat/fff-search-tools

# Conflicts:
#	bun.lock
#	packages/opencode/package.json
#	packages/opencode/src/file/index.ts
This commit is contained in:
Shoubhit Dash 2026-03-25 04:53:27 +05:30
commit f977cbe2b8
247 changed files with 9967 additions and 5661 deletions

View file

@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
export async function openPalette(page: Page, key = "K") {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()

View file

@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})

View file

@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, second)
@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, draft)
await wait(page, "")
})
})

View file

@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
expect(initialKeybind).toContain("K")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)

View file

@ -1,6 +1,16 @@
import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id })
}
})
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View file

@ -1,7 +1,7 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
.toEqual({ count: 1, first: true })
})
})
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename)
await expect
.poll(
async () => {
const state = await store(page, key)
return state?.all[0]?.title
},
{ timeout: 5_000 },
)
.toBe(rename)
})
})

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.27",
"version": "1.3.2",
"description": "",
"type": "module",
"exports": {
@ -51,9 +51,11 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",

View file

@ -6,9 +6,10 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import {
type Component,
@ -31,7 +32,7 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
@ -124,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
export function AppBaseProviders(props: ParentProps) {
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
<Font />
@ -133,14 +139,16 @@ export function AppBaseProviders(props: ParentProps) {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider>
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProviderWithNativeParser>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
<QueryProvider>
<DialogProvider>
<MarkedProviderWithNativeParser>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider>

View file

@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
@ -363,11 +363,7 @@ export function DebugBar() {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell

View file

@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@ -9,13 +9,12 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { DialogSelectModel } from "./dialog-select-model"
import { useLanguage } from "@/context/language"
import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) {
@ -35,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const methods = createMemo(
() =>
globalSync.data.provider_auth[props.provider] ?? [
{
type: "api",
label: language.t("provider.connect.method.apiKey"),
},
],
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@ -178,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
@ -297,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
onMount(() => {
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
selectMethod(0)
}
})
@ -574,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>

View file

@ -34,7 +34,6 @@ export type FormState = {
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
err: {
providerID?: string
name?: string

View file

@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
],
saving: false,
err: {},
},
t,
@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} },
],
saving: false,
err: {},
},
t,

View file

@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js"
@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
apiKey: "",
models: [modelRow()],
headers: [headerRow()],
saving: false,
err: {},
})
@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
return output.result
}
const save = async (e: SubmitEvent) => {
e.preventDefault()
if (form.saving) return
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const result = validate()
if (!result) return
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
if (result.key) {
await globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
: Promise.resolve()
}
auth
.then(() =>
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
await globalSync.updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => {
setForm("saving", false)
return result
},
onSuccess: (result) => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
}
return (
@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
</Button>
</div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
{form.saving ? language.t("common.saving") : language.t("common.submit")}
<Button
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button>
</form>
</div>

View file

@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false,
iconHover: false,
})
@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "")
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const saveMutation = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await Promise.resolve()
.then(async () => {
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
.finally(() => {
setStore("saving", false)
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
}
return (
@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
{store.saving ? language.t("common.saving") : language.t("common.save")}
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>

View file

@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js"
import { useMutation } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} finally {
setLoading(null)
}
}
},
}))
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (x) toggle(x.name)
if (!x || toggle.isPending) return
toggle.mutate(x.name)
}}
>
{(i) => {
@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
<Show when={loading() === i.name}>
<Show when={toggle.isPending && toggle.variables === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show>
</div>
@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
<Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div>
</div>
)

View file

@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@ -186,7 +187,6 @@ export function DialogSelectServer() {
name: "",
username: DEFAULT_USERNAME,
password: "",
adding: false,
error: "",
showForm: false,
status: undefined as boolean | undefined,
@ -198,7 +198,6 @@ export function DialogSelectServer() {
username: "",
password: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
},
})
@ -209,7 +208,6 @@ export function DialogSelectServer() {
name: "",
username: DEFAULT_USERNAME,
password: "",
adding: false,
error: "",
showForm: false,
status: undefined,
@ -224,10 +222,78 @@ export function DialogSelectServer() {
password: "",
error: "",
status: undefined,
busy: false,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key
const newConn = server.add(next)
@ -296,7 +362,7 @@ export function DialogSelectServer() {
}
const handleAddChange = (value: string) => {
if (store.addServer.adding) return
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
@ -304,12 +370,12 @@ export function DialogSelectServer() {
}
const handleAddNameChange = (value: string) => {
if (store.addServer.adding) return
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
@ -317,7 +383,7 @@ export function DialogSelectServer() {
}
const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
@ -325,7 +391,7 @@ export function DialogSelectServer() {
}
const handleEditChange = (value: string) => {
if (store.editServer.busy) return
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
@ -333,12 +399,12 @@ export function DialogSelectServer() {
}
const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
@ -346,85 +412,13 @@ export function DialogSelectServer() {
}
const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
@ -464,23 +458,26 @@ export function DialogSelectServer() {
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
})
}
const submitForm = () => {
if (mode() === "add") {
void handleAdd(store.addServer.url)
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
void handleEdit(original, store.editServer.value)
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")

View file

@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent()
const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))
@ -1043,7 +1044,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@ -1388,11 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) {
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
@ -1501,7 +1498,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
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)" }}
/>
@ -1533,7 +1530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
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)" }}
/>

View file

@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
const files = Array.from(clipboardData.items).flatMap((item) => {
if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (fileItems.length > 0) {
let found = false
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
if (files.length > 0) {
await addAttachments(files)
return
}
@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
let found = false
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
await addAttachments(Array.from(dropped))
}
onMount(() => {
@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return {
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}

View file

@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]

View file

@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@ -135,11 +135,14 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)

View file

@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}

View file

@ -267,14 +267,14 @@ export function SessionContextTab() {
return (
<ScrollView
class="@container h-full pb-10"
class="@container h-full"
viewportRef={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}

View file

@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
})
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`,
}}
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
>
<DropdownMenu.Item onSelect={edit}>
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
<Icon name="edit" class="w-4 h-4 mr-2" />
{language.t("common.rename")}
</DropdownMenu.Item>

View file

@ -1,27 +1,41 @@
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
@ -29,12 +43,19 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined
}
const playDemoSound = (src: string | undefined) => {
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!src) return
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo(() =>
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
] as const
const fontOptionsList = [...fontOptions]
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = (
@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.src)
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
playDemoSound(option.src)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
void loadFont().then((x) => x.ensureMonoFont(option?.value))
}}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"

View file

@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@ -15,7 +16,6 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@ -53,11 +53,15 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
@ -130,41 +134,30 @@ const useDefaultServerKey = (
}
}
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const status = input.sync.data.mcp[name]
await (status?.status === "connected"
? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
},
onError: (err) => {
showToast({
variant: "error",
title: input.language.t("common.requestFailed"),
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setLoading(null)
}
}
return { loading, toggle }
},
}))
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
@ -172,6 +165,12 @@ export function StatusPopover() {
const navigate = useNavigate()
const [shown, setShown] = createSignal(false)
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
@ -179,9 +178,9 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers)
const health = useServerHealth(servers, shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@ -310,7 +309,13 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
@ -337,8 +342,11 @@ export function StatusPopover() {
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => mcp.toggle(name)}
disabled={mcp.loading() === name}
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
@ -354,8 +362,11 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={mcp.loading() === name}
onChange={() => mcp.toggle(name)}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/>
</div>
</button>

View file

@ -1,4 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

View file

@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"

View file

@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("")
})
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
})

View file

@ -9,17 +9,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
getOwner,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
} from "solid-js"
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
@ -80,6 +70,8 @@ function createGlobalSync() {
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
onCleanup(() => {
active = false
@ -258,6 +250,11 @@ function createGlobalSync() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
global: {
config: globalStore.config,
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
store: child[0],
setStore: child[1],
@ -278,15 +275,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: queue.refresh,
refresh: () => {
if (recent) return
queue.refresh()
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
@ -325,17 +327,19 @@ function createGlobalSync() {
})
async function bootstrap() {
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootingRoot = true
try {
await bootstrapGlobal({
globalSDK: globalSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootedAt = Date.now()
} finally {
bootingRoot = false
}
}
onMount(() => {
@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return (
<Switch>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {

View file

@ -31,73 +31,102 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
})
})
}
function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
}
function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const health = await input.globalSDK.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
showToast({
variant: "error",
title: input.connectErrorTitle,
description: input.connectErrorDescription,
})
input.setGlobalStore("ready", true)
return
}
const tasks = [
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
]
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) {
const message = formatServerError(errors[0], input.translate)
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
showToast({
variant: "error",
title: input.requestFailedTitle,
description: message + more,
})
}
const slow = [
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
]
showErrors({
errors: errors(await runAll(fast)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
await waitForPaint()
showErrors({
errors: errors(await runAll(slow)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
input.setGlobalStore("ready", true)
}
@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
}) {
if (input.store.status !== "complete") input.setStore("status", "loading")
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
global: {
config: Config
project: Project[]
provider: ProviderListResponse
}
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
if (seededProject) input.setStore("project", seededProject)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
}
if (loading) input.setStore("status", "partial")
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const fast = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
]
const slow = [
() =>
retry(() =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
description: formatServerError(errs[0], input.translate),
})
input.setStore("status", "partial")
return
}
if (input.store.status !== "complete") input.setStore("status", "partial")
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
}

View file

@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])

View file

@ -1,42 +1,10 @@
import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
import { dict as ko } from "@/i18n/ko"
import { dict as de } from "@/i18n/de"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as da } from "@/i18n/da"
import { dict as ja } from "@/i18n/ja"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale =
| "en"
@ -59,6 +27,7 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
}
const base = i18n.flatten({ ...en, ...uiEn })
const DICT: Record<Locale, Dictionary> = {
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
const dicts = new Map<Locale, Dictionary>([["en", base]])
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
}
function loadDict(locale: Locale) {
const hit = dicts.get(locale)
if (hit) return Promise.resolve(hit)
if (locale === "en") return Promise.resolve(base)
const load = loaders[locale]
return load().then((next: Dictionary) => {
dicts.set(locale, next)
return next
})
}
export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
tr,
}
void PARITY_CHECK
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
@ -203,27 +170,48 @@ function detectLocale(): Locale {
return "en"
}
function normalizeLocale(value: string): Locale {
export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
} catch {
return
}
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
init: () => {
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
locale: detectLocale() as Locale,
locale: initial,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])
const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(dict, i18n.resolveTemplate)
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const label = (value: Locale) => t(LABEL_KEY[value])

View file

@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
import { playSoundById } from "@/utils/sound"
type NotificationBase = {
directory?: string
@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent()))
void playSoundById(settings.sounds.agent())
}
append({
@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors()))
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined

View file

@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
const id = store.appearance?.font ?? defaultSettings.appearance.font
if (id !== defaultSettings.appearance.font) {
void loadFont().then((x) => x.ensureMonoFont(id))
}
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
})
return {

View file

@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 200
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
input.setStore("part", p.id, p.part)
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? messagePageSize
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]

View file

@ -1,45 +1,18 @@
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const template = "Terminal {{number}}"
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
const numbered = [
template,
"محطة طرفية {{number}}",
"Терминал {{number}}",
"ターミナル {{number}}",
"터미널 {{number}}",
"เทอร์มินัล {{number}}",
"终端 {{number}}",
"終端機 {{number}}",
]
export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number))
return template.replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {

View file

@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const localUrl = () =>
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
if (location.hostname.includes("opencode.ai")) return localUrl()
if (import.meta.env.DEV) return localUrl()
if (isLocalHost()) return localUrl()
return location.origin
}

View file

@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
return projectStore.provider
if (projectStore.provider.all.length > 0) return projectStore.provider
}
return globalSync.data.provider
}

View file

@ -23,6 +23,8 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
@ -274,7 +276,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add file",
"prompt.action.attachFile": "Add files",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",

View file

@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View file

@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
return (
<DataProvider
data={sync.data}
@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const location = useLocation()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const navigate = useNavigate()
let invalid = ""
const [resolved] = createResource(
() => {
if (params.dir) return [location.pathname, params.dir] as const
},
async ([pathname, b64Dir]) => {
const directory = decode64(b64Dir)
const resolved = createMemo(() => {
if (!params.dir) return ""
return decode64(params.dir) ?? ""
})
if (!directory) {
if (invalid === params.dir) return
invalid = b64Dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
return await globalSDK
.createClient({
directory,
throwOnError: true,
})
.path.get()
.then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (resolved()) {
invalid = ""
return
}
if (invalid === dir) return
invalid = dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
})
return (
<Show when={resolved()} keyed>

View file

@ -113,6 +113,14 @@ export default function Home() {
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />

View file

@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
let dialogRun = 0
let dialogDead = false
const params = useParams()
const globalSDK = useGlobalSDK()
@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system",
@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
})
onCleanup(() => {
dialogDead = true
dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
@ -211,13 +210,22 @@ export default function Layout(props: ParentProps) {
onMount(() => {
const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
})
@ -237,6 +245,12 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
setState("hoverSession", undefined)
setHoverProject(undefined)
reset()
}
const navigateWithSidebarReset = (href: string) => {
@ -322,10 +335,9 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({
title: language.t("toast.theme.title"),
description: nextTheme?.name ?? nextThemeId,
description: theme.name(nextThemeId),
})
}
@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions()))
void playSoundById(settings.sounds.permissions())
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
@ -936,6 +948,26 @@ export default function Layout(props: ParentProps) {
navigateToSession(session)
}
function navigateProjectByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const current = currentProject()?.worktree
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
const active = current ?? fallback
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
const target =
index === -1
? offset > 0
? projects[0]
: projects[projects.length - 1]
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions()
if (sessions.length === 0) return
@ -1002,6 +1034,20 @@ export default function Layout(props: ParentProps) {
keybind: "mod+o",
onSelect: () => chooseProject(),
},
{
id: "project.previous",
title: language.t("command.project.previous"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowup",
onSelect: () => navigateProjectByOffset(-1),
},
{
id: "project.next",
title: language.t("command.project.next"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
{
id: "provider.connect",
title: language.t("command.provider.connect"),
@ -1104,10 +1150,10 @@ export default function Layout(props: ParentProps) {
},
]
for (const [id, definition] of availableThemeEntries()) {
for (const [id] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
title: language.t("command.theme.set", { theme: definition.name ?? id }),
title: language.t("command.theme.set", { theme: theme.name(id) }),
category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(),
onHighlight: () => {
@ -1158,15 +1204,27 @@ export default function Layout(props: ParentProps) {
})
function connectProvider() {
dialog.show(() => <DialogSelectProvider />)
const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
})
}
function openServer() {
dialog.show(() => <DialogSelectServer />)
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
})
}
function openSettings() {
dialog.show(() => <DialogSettings />)
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
}
function projectRoot(directory: string) {
@ -1393,7 +1451,13 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
const showEditProjectDialog = (project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
async function chooseProject() {
function resolve(result: string | string[] | null) {
@ -1414,10 +1478,14 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
})
}
}
@ -1750,6 +1818,9 @@ export default function Layout(props: ParentProps) {
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
const panel = createMemo(() => Math.max(side() - 64, 0))
const loadedSessionDirs = new Set<string>()
createEffect(
@ -1941,6 +2012,10 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,
@ -2022,7 +2097,7 @@ export default function Layout(props: ParentProps) {
"max-w-full overflow-hidden": panelProps.mobile,
}}
style={{
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
width: panelProps.mobile ? undefined : `${panel()}px`,
}}
>
<Show
@ -2085,9 +2160,11 @@ export default function Layout(props: ParentProps) {
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
@ -2312,7 +2389,7 @@ export default function Layout(props: ParentProps) {
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
@ -2327,26 +2404,29 @@ export default function Layout(props: ParentProps) {
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setState("sizing", true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
</Show>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
@ -2386,7 +2466,7 @@ export default function Layout(props: ParentProps) {
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
@ -2433,7 +2513,7 @@ export default function Layout(props: ParentProps) {
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>

View file

@ -104,7 +104,7 @@ const SessionRow = (props: {
}): 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"}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
@ -115,30 +115,26 @@ const SessionRow = (props: {
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>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<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>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A>
)
@ -157,34 +153,49 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={props.trigger}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
@ -298,62 +309,71 @@ 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 min-w-0 rounded-md cursor-default pl-2 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
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
{item}
</Tooltip>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
<div class="flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Show
when={hoverEnabled()}
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={props.session.title}
gutter={10}
class="min-w-0 w-full"
>
{item}
</Tooltip>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
</div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
trigger={item}
/>
</Show>
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{
"opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div>
</div>
)
@ -375,30 +395,26 @@ export const NewSessionItem = (props: {
<A
href={`/${props.slug}/session`}
end
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}
>
<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" />
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label}
</span>
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A>
)
return (
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<Show
when={!tooltip()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
{item}
</Tooltip>
}

View file

@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void
openSidebar: () => void
closeProject: (directory: string) => void
@ -109,8 +110,14 @@ const ProjectTile = (props: {
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onPointerDown={(event) => {
if (event.button === 0 && !event.ctrlKey) {
props.setOpen(false)
props.setSuppressHover(true)
return
}
if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setOpen(false)
props.setSuppressHover(true)
event.preventDefault()
}}
@ -130,12 +137,11 @@ const ProjectTile = (props: {
props.onProjectFocus(props.project.worktree)
}}
onClick={() => {
props.setOpen(false)
if (props.selected()) {
props.setSuppressHover(true)
layout.sidebar.toggle()
return
}
props.setSuppressHover(false)
props.navigateToProject(props.project.worktree)
}}
onBlur={() => props.setOpen(false)}
@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
props.ctx.openSidebar()
props.setOpen(false)
props.ctx.onHoverOpenChanged(props.project.worktree, false)
if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree)
}}
@ -284,28 +289,16 @@ export const SortableProject = (props: {
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({
open: false,
menu: false,
suppressHover: false,
})
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo(
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
createEffect(() => {
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
@ -346,7 +339,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)}
setOpen={(value) => setState("open", value)}
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setSuppressHover={(value) => setState("suppressHover", value)}
language={language}
/>
@ -357,7 +350,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}>
<HoverCard
open={!state.suppressHover && state.open && !state.menu}
open={!state.suppressHover && hoverOpen() && !state.menu}
openDelay={0}
closeDelay={0}
placement="right-start"
@ -366,7 +359,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => {
if (state.menu) return
if (value && state.suppressHover) return
setState("open", value)
props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
@ -381,7 +374,6 @@ export const SortableProject = (props: {
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx}
language={language}
/>

View file

@ -1,5 +1,6 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
batch,
onCleanup,
@ -40,7 +41,13 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
import {
createOpenReviewFile,
createSessionTabs,
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
} from "@/pages/session/helpers"
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"
@ -239,14 +246,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
return
}
if (turnStart() !== start) return
const reveal = !opts?.prefetch
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
}
const onScrollerScroll = () => {
@ -327,10 +339,7 @@ export default function Page() {
})
const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false,
scrollGesture: 0,
scroll: {
@ -506,7 +515,6 @@ export default function Page() {
const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>,
edit: {} as Record<
@ -644,25 +652,24 @@ export default function Page() {
globalSync.set("project", [...list, next])
}
const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
},
}))
function initGit() {
if (ui.git) return
setUi("git", true)
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
if (gitMutation.isPending) return
gitMutation.mutate()
}
let inputRef!: HTMLDivElement
@ -854,7 +861,7 @@ export default function Page() {
// Prefer the open terminal over the composer when it can take focus
if (view().terminal.opened()) {
const id = terminal.active()
if (id && focusTerminalById(id)) return
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures.
@ -961,8 +968,8 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={ui.git} onClick={initGit}>
{ui.git
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
@ -1177,8 +1184,6 @@ export default function Page() {
on(
() => sdk.directory,
() => {
void file.tree.list("")
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@ -1379,10 +1384,40 @@ export default function Page() {
return followup.edit[id]
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
if (input.manual) setFollowup("paused", input.sessionID, undefined)
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
return false
})
if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
if (input.manual) resumeScroll()
},
}))
const followupBusy = (sessionID: string) =>
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => {
const id = params.id
if (!id) return
return followup.sending[id]
if (!followupBusy(id)) return
return followupMutation.variables?.id
})
const queueEnabled = createMemo(() => {
@ -1422,37 +1457,15 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followup.sending[sessionID]) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
if (opts?.manual) setFollowup("paused", sessionID, undefined)
setFollowup("sending", sessionID, id)
setFollowup("failed", sessionID, undefined)
return sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
})
.then((ok) => {
if (ok === false) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
if (opts?.manual) resumeScroll()
})
.catch((err) => {
setFollowup("failed", sessionID, id)
fail(err)
})
.finally(() => {
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
})
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
}
const editFollowup = (id: string) => {
const sessionID = params.id
if (!sessionID) return
if (followup.sending[sessionID]) return
if (followupBusy(sessionID)) return
const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return
@ -1475,6 +1488,74 @@ export default function Page() {
const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
await halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = params.id
if (!sessionID) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
await task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID)
const dir = base64Encode(sdk.directory)
@ -1496,77 +1577,13 @@ export default function Page() {
}
const revert = (input: { sessionID: string; messageID: string }) => {
if (ui.reverting || ui.restoring) return
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
})
if (reverting()) return
return revertMutation.mutateAsync(input)
}
const restore = (id: string) => {
const sessionID = params.id
if (!sessionID || ui.restoring || ui.reverting) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
return task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
if (!params.id || reverting()) return
return restoreMutation.mutateAsync(id)
}
const rolled = createMemo(() => {
@ -1585,7 +1602,7 @@ export default function Page() {
const item = queuedFollowups()[0]
if (!item) return
if (followup.sending[sessionID]) return
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (composer.blocked()) return
@ -1621,6 +1638,9 @@ export default function Page() {
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
@ -1692,7 +1712,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={lastUserMessage()}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
@ -1780,8 +1800,8 @@ export default function Page() {
rolled().length > 0
? {
items: rolled(),
restoring: ui.restoring,
disabled: ui.reverting,
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined

View file

@ -1,5 +1,6 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
})
let root: HTMLDivElement | undefined
@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
showToast({ title: language.t("common.requestFailed"), description: message })
}
const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
},
onError: fail,
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
},
onError: fail,
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
await replyMutation.mutateAsync(answers)
}
const reject = async () => {
if (store.sending) return
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
if (sending()) return
await rejectMutation.mutateAsync()
}
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const customToggle = () => {
if (store.sending) return
if (sending()) return
if (!multi()) {
setStore("customOn", store.tab, true)
@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const customOpen = () => {
if (store.sending) return
if (sending()) return
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
}
const selectOption = (optIndex: number) => {
if (store.sending) return
if (sending()) return
if (optIndex === options().length) {
customOpen()
@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const next = () => {
if (store.sending) return
if (sending()) return
if (store.editing) commitCustom()
if (store.tab >= total() - 1) {
@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const back = () => {
if (store.sending) return
if (sending()) return
if (store.tab <= 0) return
setStore("tab", store.tab - 1)
setStore("editing", false)
}
const jump = (tab: number) => {
if (store.sending) return
if (sending()) return
setStore("tab", tab)
setStore("editing", false)
}
@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
footer={
<>
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
disabled={sending()}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
disabled={sending()}
onClick={customOpen}
>
<span
@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
if (sending()) {
e.preventDefault()
return
}
@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()

View file

@ -7,6 +7,7 @@ import {
createSessionTabs,
focusTerminalById,
getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
} from "./helpers"
describe("createOpenReviewFile", () => {
@ -86,6 +87,26 @@ describe("focusTerminalById", () => {
})
})
describe("shouldFocusTerminalOnKeyDown", () => {
test("skips pure modifier keys", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
})
test("skips shortcut key combos", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
})
test("keeps plain typing focused on terminal", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
})
})
describe("getTabReorderIndex", () => {
test("returns target index for valid drag reorder", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)

View file

@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => {
return true
}
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
if (skip.has(event.key)) return false
return !(event.ctrlKey || event.metaKey || event.altKey)
}
export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string

View file

@ -1,6 +1,7 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
@ -29,6 +30,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
type MessageComment = {
path: string
@ -249,38 +251,21 @@ export function MessageTimeline(props: {
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({
open: false,
show: false,
fade: false,
const [timeoutDone, setTimeoutDone] = createSignal(true)
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
if (working()) return "showing"
if (prev === "showing" || !timeoutDone()) return "hiding"
return "hidden"
})
let f: number | undefined
const clear = () => {
if (f !== undefined) window.clearTimeout(f)
f = undefined
}
createEffect(() => {
if (workingStatus() !== "hiding") return
setTimeoutDone(false)
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
onCleanup(clear)
createEffect(
on(
working,
(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 },
),
)
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
@ -321,7 +306,6 @@ export function MessageTimeline(props: {
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
pendingShare: false,
@ -335,38 +319,6 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = sessionID()
if (!id || req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
.finally(() => {
setReq("share", false)
})
}
const unshareSession = () => {
const id = sessionID()
if (!id || req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
.finally(() => {
setReq("unshare", false)
})
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
@ -382,6 +334,54 @@ export function MessageTimeline(props: {
return language.t("common.requestFailed")
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk.client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
createEffect(
on(
sessionKey,
@ -389,7 +389,6 @@ export function MessageTimeline(props: {
setTitle({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
pendingShare: false,
@ -408,40 +407,22 @@ export function MessageTimeline(props: {
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
if (titleMutation.isPending) return
setTitle("editing", false)
}
const saveTitleEditor = async () => {
const saveTitleEditor = () => {
const id = sessionID()
if (!id) return
if (title.saving) return
if (titleMutation.isPending) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
setTitle("editing", false)
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
titleMutation.mutate({ id, title: next })
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
@ -679,17 +660,15 @@ export function MessageTimeline(props: {
<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",
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<Show when={workingStatus() !== "hidden"}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div>
@ -712,7 +691,7 @@ export function MessageTimeline(props: {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
@ -863,9 +842,9 @@ export function MessageTimeline(props: {
variant="primary"
class="w-full"
onClick={shareSession}
disabled={req.share}
disabled={shareMutation.isPending}
>
{req.share
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
@ -886,9 +865,9 @@ export function MessageTimeline(props: {
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={req.unshare}
disabled={unshareMutation.isPending}
>
{req.unshare
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
@ -897,7 +876,7 @@ export function MessageTimeline(props: {
variant="primary"
class="w-full"
onClick={viewShare}
disabled={req.unshare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
@ -915,7 +894,6 @@ export function MessageTimeline(props: {
</div>
</div>
</Show>
<div
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
@ -945,7 +923,15 @@ export function MessageTimeline(props: {
{(messageID) => {
const active = createMemo(() => activeMessageID() === messageID)
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
equals: (a, b) =>
a.length === b.length &&
a.every(
(c, i) =>
c.path === b[i].path &&
c.comment === b[i].comment &&
c.selection?.startLine === b[i].selection?.startLine &&
c.selection?.endLine === b[i].selection?.endLine,
),
})
const commentCount = createMemo(() => comments().length)
return (
@ -1001,6 +987,7 @@ export function MessageTimeline(props: {
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={messageID}
messages={sessionMessages()}
actions={props.actions}
active={active()}
status={active() ? sessionStatus() : undefined}

View file

@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>

View file

@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),

View file

@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
queue(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"

View file

@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
import { extractPromptFromParts } from "./prompt"
describe("extractPromptFromParts", () => {
test("restores multiple uploaded attachments", () => {
const parts = [
{
id: "text_1",
type: "text",
text: "check these",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_1",
type: "file",
mime: "image/png",
url: "data:image/png;base64,AAA",
filename: "a.png",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_2",
type: "file",
mime: "application/pdf",
url: "data:application/pdf;base64,BBB",
filename: "b.pdf",
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]
const result = extractPromptFromParts(parts)
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
expect(result.slice(1)).toMatchObject([
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
])
})
})

View file

@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
const defaultTimeoutMs = 3000
const defaultRetryCount = 2
const defaultRetryDelayMs = 100
const cacheMs = 750
const healthCache = new Map<
string,
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@ -87,5 +96,18 @@ export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
return (http: ServerConnection.HttpBase) => {
const key = cacheKey(http)
const hit = healthCache.get(key)
const now = Date.now()
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
const promise = checkServerHealth(http, fetcher).finally(() => {
const next = healthCache.get(key)
if (!next || next.promise !== promise) return
next.done = true
next.at = Date.now()
})
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
return promise
}
}

View file

@ -1,106 +1,89 @@
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
let files: Record<string, () => Promise<string>> | undefined
let loads: Record<SoundID, () => Promise<string>> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
string,
() => Promise<string>
>
return files
}
export const SOUND_OPTIONS = [
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
{ id: "alert-01", label: "sound.option.alert01" },
{ id: "alert-02", label: "sound.option.alert02" },
{ id: "alert-03", label: "sound.option.alert03" },
{ id: "alert-04", label: "sound.option.alert04" },
{ id: "alert-05", label: "sound.option.alert05" },
{ id: "alert-06", label: "sound.option.alert06" },
{ id: "alert-07", label: "sound.option.alert07" },
{ id: "alert-08", label: "sound.option.alert08" },
{ id: "alert-09", label: "sound.option.alert09" },
{ id: "alert-10", label: "sound.option.alert10" },
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
{ id: "staplebops-01", label: "sound.option.staplebops01" },
{ id: "staplebops-02", label: "sound.option.staplebops02" },
{ id: "staplebops-03", label: "sound.option.staplebops03" },
{ id: "staplebops-04", label: "sound.option.staplebops04" },
{ id: "staplebops-05", label: "sound.option.staplebops05" },
{ id: "staplebops-06", label: "sound.option.staplebops06" },
{ id: "staplebops-07", label: "sound.option.staplebops07" },
{ id: "nope-01", label: "sound.option.nope01" },
{ id: "nope-02", label: "sound.option.nope02" },
{ id: "nope-03", label: "sound.option.nope03" },
{ id: "nope-04", label: "sound.option.nope04" },
{ id: "nope-05", label: "sound.option.nope05" },
{ id: "nope-06", label: "sound.option.nope06" },
{ id: "nope-07", label: "sound.option.nope07" },
{ id: "nope-08", label: "sound.option.nope08" },
{ id: "nope-09", label: "sound.option.nope09" },
{ id: "nope-10", label: "sound.option.nope10" },
{ id: "nope-11", label: "sound.option.nope11" },
{ id: "nope-12", label: "sound.option.nope12" },
{ id: "yup-01", label: "sound.option.yup01" },
{ id: "yup-02", label: "sound.option.yup02" },
{ id: "yup-03", label: "sound.option.yup03" },
{ id: "yup-04", label: "sound.option.yup04" },
{ id: "yup-05", label: "sound.option.yup05" },
{ id: "yup-06", label: "sound.option.yup06" },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
function getLoads() {
if (loads) return loads
loads = Object.fromEntries(
Object.entries(getFiles()).flatMap(([path, load]) => {
const file = path.split("/").at(-1)
if (!file) return []
return [[file.replace(/\.aac$/, ""), load] as const]
}),
) as Record<SoundID, () => Promise<string>>
return loads
}
const cache = new Map<SoundID, Promise<string | undefined>>()
export function soundSrc(id: string | undefined) {
if (!id) return
if (!(id in soundById)) return
return soundById[id as SoundID]
const loads = getLoads()
if (!id || !(id in loads)) return Promise.resolve(undefined)
const key = id as SoundID
const hit = cache.get(key)
if (hit) return hit
const next = loads[key]().catch(() => undefined)
cache.set(key, next)
return next
}
export function playSound(src: string | undefined) {
@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
if (!src) return
const audio = new Audio(src)
audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}

View file

@ -1,7 +1,10 @@
import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
/**
* @type {import("vite").PluginOption}
*/
@ -21,6 +24,15 @@ export default [
}
},
},
{
name: "opencode-desktop:theme-preload",
transformIndexHtml(html) {
return html.replace(
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
)
},
},
tailwindcss(),
solidPlugin(),
]

View file

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

View file

@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
export async function GET() {
return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
)
}

View file

@ -24,7 +24,13 @@ import {
FreeUsageLimitError,
SubscriptionUsageLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import {
buildCostChunk,
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
UsageInfo,
} from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
@ -90,7 +96,7 @@ export async function handler(
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
logger.metric({
is_tream: isStream,
is_stream: isStream,
session: sessionId,
request: requestId,
client: ocClient,
@ -230,7 +236,7 @@ export async function handler(
const body = JSON.stringify(
responseConverter({
...json,
cost: calculateOccuredCost(billingSource, costInfo),
cost: calculateOccurredCost(billingSource, costInfo),
}),
)
logger.metric({ response_length: body.length })
@ -274,8 +280,8 @@ export async function handler(
await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccuredCost(billingSource, costInfo)
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
const cost = calculateOccurredCost(billingSource, costInfo)
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
}
c.close()
return
@ -334,6 +340,13 @@ export async function handler(
"error.message": error.message,
"error.cause": error.cause?.toString(),
})
if (error.message.startsWith("Failed query")) {
try {
logger.metric({
"error.cause2": JSON.stringify(error.cause),
})
} catch (e) {}
}
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if (
@ -455,12 +468,17 @@ export async function handler(
...modelProvider,
...zenData.providers[modelProvider.id],
...(() => {
const format = zenData.providers[modelProvider.id].format
const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format
const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({ reqModel, providerModel })
return oaCompatHelper({
reqModel,
providerModel,
adjustCacheUsage: providerProps.adjustCacheUsage,
})
})(),
}
}
@ -818,7 +836,7 @@ export async function handler(
}
}
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
}

View file

@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
const isBedrock = isBedrockModelArn || isBedrockModelID
const isDatabricks = providerModel.startsWith("databricks-claude-")
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
return {
format: "anthropic",
@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
: providerApi + "/messages",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
if (isBedrock) {
if (isBedrock || isDatabricks) {
headers.set("Authorization", `Bearer ${apiKey}`)
} else {
headers.set("x-api-key", apiKey)
@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
model: undefined,
stream: undefined,
}
: {
service_tier: "standard_only",
}),
: isDatabricks
? {
anthropic_version: "bedrock-2023-05-31",
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
}
: {
service_tier: "standard_only",
}),
}),
createBinaryStreamDecoder: () => {
if (!isBedrock) return undefined
@ -167,7 +173,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
}
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => ({

View file

@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
usage = json.usageMetadata
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {

View file

@ -21,7 +21,7 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = () => ({
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
@ -54,14 +54,18 @@ export const oaCompatHelper: ProviderHelper = () => ({
usage = json.usage
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.prompt_tokens ?? 0
let inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
if (adjustCacheUsage && !cacheReadTokens) {
cacheReadTokens = Math.floor(inputTokens * 0.9)
}
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,

View file

@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
usage = json.response.usage
},
retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
}
},
normalizeUsage: (usage: Usage) => {

View file

@ -33,7 +33,7 @@ export type UsageInfo = {
cacheWrite1hTokens?: number
}
export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => {
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any
buidlCostChunk: (cost: string) => string
}
normalizeUsage: (usage: any) => UsageInfo
}
@ -162,6 +161,19 @@ export interface CommonChunk {
}
}
export function buildCostChunk(format: ZenData.Format, cost: string): string {
switch (format) {
case "anthropic":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "openai":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "oa-compat":
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
default:
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
}
}
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => {
if (from === to) return body

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.27",
"version": "1.3.2",
"private": true,
"type": "module",
"license": "MIT",
@ -42,7 +42,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "catalog:",
"mysql2": "3.14.4",

View file

@ -1,7 +1,14 @@
import { Database, and, eq, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
import {
BillingTable,
PaymentTable,
SubscriptionTable,
BlackPlans,
UsageTable,
LiteTable,
} from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js"
@ -72,11 +79,13 @@ else {
workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name,
role: UserTable.role,
subscribed: SubscriptionTable.timeCreated,
black: SubscriptionTable.timeCreated,
lite: LiteTable.timeCreated,
})
.from(UserTable)
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
.where(eq(UserTable.accountID, accountID))
.then((rows) =>
rows.map((row) => ({
@ -84,7 +93,8 @@ else {
workspaceID: row.workspaceID,
workspaceName: row.workspaceName,
role: row.role,
subscribed: formatDate(row.subscribed),
black: formatDate(row.black),
lite: formatDate(row.lite),
})),
),
)
@ -151,13 +161,14 @@ async function printWorkspace(workspaceID: string) {
balance: BillingTable.balance,
customerID: BillingTable.customerID,
reload: BillingTable.reload,
subscriptionID: BillingTable.subscriptionID,
subscription: {
blackSubscriptionID: BillingTable.subscriptionID,
blackSubscription: {
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
enrichment: BillingTable.subscription,
},
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
liteSubscriptionID: BillingTable.liteSubscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
@ -167,16 +178,21 @@ async function printWorkspace(workspaceID: string) {
balance: `$${(row.balance / 100000000).toFixed(2)}`,
reload: row.reload ? "yes" : "no",
customerID: row.customerID,
subscriptionID: row.subscriptionID,
subscription: row.subscriptionID
liteSubscriptionID: row.liteSubscriptionID,
blackSubscriptionID: row.blackSubscriptionID,
blackSubscription: row.blackSubscriptionID
? [
`Black ${row.subscription.enrichment!.plan}`,
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
`(ref: ${row.subscriptionID})`,
`Black ${row.blackSubscription.enrichment!.plan}`,
row.blackSubscription.enrichment!.seats > 1
? `X ${row.blackSubscription.enrichment!.seats} seats`
: "",
row.blackSubscription.enrichment!.coupon
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
: "",
`(ref: ${row.blackSubscriptionID})`,
].join(" ")
: row.subscription.booked
? `Waitlist ${row.subscription.plan} plan${row.timeSubscriptionSelected ? " (selected)" : ""}`
: row.blackSubscription.booked
? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
: undefined,
}))[0],
),

View file

@ -48,6 +48,7 @@ export namespace ZenData {
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
})
const ModelsSchema = z.object({

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.27",
"version": "1.3.2",
"$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.27",
"version": "1.3.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View file

@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.5
ARG BUN_VERSION=1.3.11
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

View file

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

View file

@ -35,6 +35,7 @@ export type CommandEvent =
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
treeKill(child.pid)
}
return { events, child: { kill }, exit }
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {

View file

@ -81,6 +81,17 @@ function setupApp() {
killSidecar()
})
app.on("will-quit", () => {
killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
})
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
@ -234,8 +245,15 @@ registerIpcHandlers({
function killSidecar() {
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
}
function ensureLoopbackNoProxy() {

View file

@ -6,6 +6,9 @@ import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
@ -246,6 +249,17 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount())
@ -257,6 +271,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
@ -309,15 +324,14 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
disableHealthCheck={(windowCount() ?? 0) > 1}
>
<Inner />
</AppInterface>

View file

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

View file

@ -6,6 +6,9 @@ import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
@ -414,6 +417,17 @@ void listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
// Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@ -423,6 +437,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
// Build the sidecar server connection once credentials arrive
const servers = () => {
@ -465,8 +480,8 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
{(_) => {
return (
<AppInterface

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.27",
"version": "1.3.2",
"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.27"
version = "1.3.2"
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.27/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/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.27/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/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.27/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/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.27/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View file

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

0
packages/opencode/git Normal file
View file

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.27",
"version": "1.3.2",
"name": "opencode",
"type": "module",
"license": "MIT",
@ -91,8 +91,6 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-node": "0.4.2",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@ -104,8 +102,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@ -123,6 +121,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@ -133,8 +132,10 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View file

@ -101,6 +101,14 @@ export default {
],
},
},
{
filetype: "kotlin",
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
},
},
{
filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
@ -158,6 +166,15 @@ export default {
// },
// },
},
{
filetype: "hcl",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
],
},
},
{
filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
@ -203,6 +220,16 @@ export default {
],
},
},
{
filetype: "lua",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
],
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
},
},
{
filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
@ -236,6 +263,15 @@ export default {
],
},
},
{
filetype: "toml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
],
},
},
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published

View file

@ -21,10 +21,14 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
console.log("Generated models-snapshot.ts")
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
// Load migrations from migration directories
const migrationDirs = (
@ -199,6 +203,19 @@ for (const item of targets) {
},
})
// Smoke test: only run if binary is for current platform
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
const binaryPath = `dist/${name}/bin/opencode`
console.log(`Running smoke test: ${binaryPath} --version`)
try {
const versionOutput = await $`${binaryPath} --version`.text()
console.log(`Smoke test passed: ${versionOutput.trim()}`)
} catch (e) {
console.error(`Smoke test failed for ${name}:`, e)
process.exit(1)
}
}
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(

View file

@ -11,7 +11,6 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
@ -55,7 +54,6 @@ const seed = async () => {
})
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
}

View file

@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
## Choose scope
Use the shared runtime for process-wide services with one lifecycle for the whole app.
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
- Shared runtime: config readers, stateless helpers, global clients
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
- Global services (no per-directory state): Account, Auth, Installation, Truncate
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
## Service shape
For a fully migrated module, use the public namespace directly:
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
```ts
export namespace Foo {
@ -28,53 +28,86 @@ export namespace Foo {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
get: Effect.fn("Foo.get")(function* (id) {
return yield* ...
}),
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const runPromise = makeRunPromise(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
}
```
Rules:
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
- Export `defaultLayer` only when wiring dependencies is useful
- Use the direct namespace form once the module is fully migrated
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
## Temporary mixed-mode pattern
## Schema → Zod interop
Prefer a single namespace whenever possible.
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
```ts
export namespace FooEffect {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
}
import { zod } from "@/util/effect-zod"
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(...)
}
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
```
Then keep the old boundary thin:
See `Auth.ZodInfo` for the canonical example.
## InstanceState init patterns
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
```ts
export namespace Foo {
export function get(id: FooID) {
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
}
}
const cache =
yield *
InstanceState.make<State>(
Effect.fn("Foo.state")(function* (ctx) {
// ... load state ...
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribeAll((event) => {
/* handle */
}),
),
(unsub) => Effect.sync(unsub),
)
return {
/* state */
}
}),
)
```
Remove the `Effect` suffix when the boundary split is gone.
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
## Scheduled Tasks
@ -107,38 +140,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist
Done now:
Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `AccountEffect` (mixed-mode)
- [x] `AuthEffect` (mixed-mode)
- [x] `TruncateEffect` (mixed-mode)
- [x] `Question`
- [x] `PermissionNext`
- [x] `ProviderAuth`
- [x] `FileWatcher`
- [x] `FileTime`
- [x] `Format`
- [x] `Vcs`
- [x] `Skill`
- [x] `Discovery`
- [x] `File`
- [x] `Snapshot`
- [x] `Account``account/index.ts`
- [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
- [x] `File``file/index.ts`
- [x] `FileTime``file/time.ts`
- [x] `FileWatcher``file/watcher.ts`
- [x] `Format``format/index.ts`
- [x] `Installation``installation/index.ts`
- [x] `Permission``permission/index.ts`
- [x] `ProviderAuth``provider/auth.ts`
- [x] `Question``question/index.ts`
- [x] `Skill``skill/index.ts`
- [x] `Snapshot``snapshot/index.ts`
- [x] `Truncate``tool/truncate.ts`
- [x] `Vcs``project/vcs.ts`
- [x] `Discovery``skill/discovery.ts`
- [x] `SessionStatus`
Still open and likely worth migrating:
- [ ] `Plugin`
- [ ] `ToolRegistry`
- [x] `Plugin`
- [x] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus`
- [ ] `Command`
- [x] `Command`
- [ ] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [ ] `Project`
- [x] `Project`
- [ ] `LSP`
- [ ] `MCP`

View file

@ -1,380 +0,0 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}

View file

@ -1,34 +1,397 @@
import { Effect, Option } from "effect"
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export { AccessToken, AccountID, OrgID } from "./effect"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(S.Service.use(f))
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(S.Service.use(f))
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace Account {
export const Info = Model
export type Info = Model
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export function active(): Info | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const runPromise = makeRunPromise(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config)
const cfg = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(cfg)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token)
const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t)
}
}

View file

@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,

View file

@ -3,7 +3,6 @@ import z from "zod"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate"
import { Auth } from "../auth"
@ -14,12 +13,15 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission"
import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
export namespace Agent {
export const Info = z
@ -32,7 +34,7 @@ export namespace Agent {
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: PermissionNext.Ruleset,
permission: Permission.Ruleset,
model: z
.object({
modelID: ModelID.zod,
@ -49,295 +51,364 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>
const state = Instance.state(async () => {
const cfg = await Config.get()
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
type State = Omit<Interface, "generate">
const result: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = () => Effect.promise(() => Config.get())
const auth = yield* Auth.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config()
const skillDirs = yield* Effect.promise(() => Skill.dirs())
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
permission: PermissionNext.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
const user = Permission.fromConfig(cfg.permission ?? {})
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in result) {
const agent = result[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
result[name].permission = PermissionNext.merge(
result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
)
}
return result
})
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config()
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export async function get(agent: string) {
return state().then((x) => x[agent])
return runPromise((svc) => svc.get(agent))
}
export async function list() {
const cfg = await Config.get()
return pipe(
await state(),
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
return runPromise((svc) => svc.list())
}
export async function defaultAgent() {
const cfg = await Config.get()
const agents = await state()
if (cfg.default_agent) {
const agent = agents[cfg.default_agent]
if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
return agent.name
}
const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!primaryVisible) throw new Error("no primary visible agent found")
return primaryVisible.name
return runPromise((svc) => svc.defaultAgent())
}
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
const cfg = await Config.get()
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = [PROMPT_GENERATE]
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
const existing = await list()
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
return result.object
return runPromise((svc) => svc.generate(input))
}
}

View file

@ -1,94 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace Auth {
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
}

View file

@ -1,43 +1,101 @@
import { Effect } from "effect"
import z from "zod"
import { runtime } from "@/effect/runtime"
import * as S from "./effect"
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRunPromise } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export { OAUTH_DUMMY_KEY } from "./effect"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.Auth.Service.use(f))
}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
export namespace Auth {
export const Oauth = z
.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(),
})
.meta({ ref: "OAuth" })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export const Api = z
.object({
type: z.literal("api"),
key: z.string(),
})
.meta({ ref: "ApiAuth" })
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export const WellKnown = z
.object({
type: z.literal("wellknown"),
key: z.string(),
token: z.string(),
})
.meta({ ref: "WellKnownAuth" })
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export type Info = z.infer<typeof Info>
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))

View file

@ -1,8 +1,7 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
@ -11,6 +10,26 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
`${org.name} (${account.email})${activeSuffix(isActive)}`
export const formatOrgLine = (
account: { email: string; url: string },
org: { id: string; name: string },
isActive: boolean,
) => {
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
}
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
@ -77,10 +96,9 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return {
value: a,
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
label: formatAccountLabel(a, isActive),
}
})
@ -110,9 +128,7 @@ const switchEffect = Effect.fn("switch")(function* () {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
label: formatOrgChoiceLabel(group.account, org, isActive),
}
}),
)
@ -140,15 +156,21 @@ const orgsEffect = Effect.fn("orgs")(function* () {
for (const group of groups) {
for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
yield* println(formatOrgLine(group.account, org, isActive))
}
}
})
const openEffect = Effect.fn("open")(function* () {
const service = yield* Account.Service
const active = yield* service.active()
if (Option.isNone(active)) return yield* println("No active account")
const url = active.value.url
yield* openBrowser(url)
yield* Prompt.outro("Opened " + url)
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
@ -160,7 +182,7 @@ export const LoginCommand = cmd({
}),
async handler(args) {
UI.empty()
await runtime.runPromise(loginEffect(args.url))
await Account.runPromise((_svc) => loginEffect(args.url))
},
})
@ -174,7 +196,7 @@ export const LogoutCommand = cmd({
}),
async handler(args) {
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
await Account.runPromise((_svc) => logoutEffect(args.email))
},
})
@ -183,7 +205,7 @@ export const SwitchCommand = cmd({
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(switchEffect())
await Account.runPromise((_svc) => switchEffect())
},
})
@ -192,7 +214,16 @@ export const OrgsCommand = cmd({
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(orgsEffect())
await Account.runPromise((_svc) => orgsEffect())
},
})
export const OpenCommand = cmd({
command: "open",
describe: false,
async handler() {
UI.empty()
await Account.runPromise((_svc) => openEffect())
},
})
@ -217,6 +248,10 @@ export const ConsoleCommand = cmd({
...OrgsCommand,
describe: "list orgs",
})
.command({
...OpenCommand,
describe: "open active console account",
})
.demandCommand(),
async handler() {},
})

View file

@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission"
import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = PermissionNext.disabled(
const disabled = Permission.disabled(
availableTools.map((tool) => tool.id),
agent.permission,
)
@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
}
await Session.updateMessage(message)
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
return {
sessionID: session.id,
@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal,
messages: [],
metadata: () => {},
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset })
throw new Permission.DeniedError({ ruleset })
}
}
},

View file

@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission"
import { Permission } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
@ -354,7 +354,7 @@ export const RunCommand = cmd({
process.exit(1)
}
const rules: PermissionNext.Ruleset = [
const rules: Permission.Ruleset = [
{
permission: "question",
action: "deny",

View file

@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
@ -29,6 +29,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
@ -103,11 +104,13 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}
import type { EventSource } from "./context/sdk"
import { Installation } from "@/installation"
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@ -158,7 +161,7 @@ export function tui(input: {
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
@ -199,7 +202,7 @@ export function tui(input: {
})
}
function App() {
function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@ -210,7 +213,7 @@ function App() {
const command = useCommandDialog()
const sdk = useSDK()
const toast = useToast()
const { theme, mode, setMode } = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
@ -555,7 +558,7 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: "Toggle Theme Mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
@ -563,6 +566,16 @@ function App() {
},
category: "System",
},
{
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()
else lock()
dialog.clear()
},
category: "System",
},
{
title: "Help",
value: "help.show",
@ -615,11 +628,11 @@ function App() {
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: (dialog) => {
const path = writeHeapSnapshot()
onSelect: async (dialog) => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${path}`,
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
@ -729,13 +742,51 @@ function App() {
})
})
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
sdk.event.on("installation.update-available", async (evt) => {
const version = evt.properties.version
const skipped = kv.get("skipped_version")
if (skipped && !semver.gt(version, skipped)) return
const choice = await DialogConfirm.show(
dialog,
`Update Available`,
`A new release v${version} is available. Would you like to update now?`,
"skip",
)
if (choice === false) {
kv.set("skipped_version", version)
return
}
if (choice !== true) return
toast.show({
variant: "info",
title: "Update Available",
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
duration: 10000,
message: `Updating to v${version}...`,
duration: 30000,
})
const result = await sdk.client.global.upgrade({ target: version })
if (result.error || !result.data?.success) {
toast.show({
variant: "error",
title: "Update Failed",
message: "Update failed",
duration: 10000,
})
return
}
await DialogAlert.show(
dialog,
"Update Complete",
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
)
exit()
})
return (

View file

@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@ -934,7 +934,7 @@ export function Prompt(props: PromptProps) {
// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
if (!pastedContent) {
command.trigger("prompt.paste")

View file

@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
message: store,
},
)
process.on("SIGHUP", () => exit())
return exit
},
})

View file

@ -1,6 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@ -280,11 +280,18 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const renderer = useRenderer()
const config = useTuiConfig()
const kv = useKV()
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
const lock = pick(kv.get("theme_mode_lock"))
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
lock,
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
@ -295,7 +302,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
resolveSystemTheme()
resolveSystemTheme(store.mode)
getCustomThemes()
.then((custom) => {
setStore(
@ -316,14 +323,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
onMount(init)
function resolveSystemTheme() {
console.log("resolveSystemTheme")
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
renderer
.getPalette({
size: 16,
})
.then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) {
if (store.active === "system") {
setStore(
@ -337,7 +342,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
draft.themes.system = generateSystem(colors, mode)
if (store.active === "system") {
draft.ready = true
}
@ -346,16 +351,44 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
}
const renderer = useRenderer()
process.on("SIGUSR2", async () => {
function apply(mode: "dark" | "light") {
kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache()
init()
resolveSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
}
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
const handle = (mode: "dark" | "light") => {
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
})
const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
})
createEffect(() => {
renderer.setBackgroundColor(values().background)
})
const syntax = createMemo(() => generateSyntax(values()))
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
@ -377,9 +410,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() {
return store.mode
},
locked() {
return store.lock !== undefined
},
lock() {
pin(store.mode)
},
unlock() {
free()
},
setMode(mode: "dark" | "light") {
setStore("mode", mode)
kv.set("theme_mode", mode)
pin(mode)
},
set(theme: string) {
setStore("active", theme)
@ -428,7 +469,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromInts(0, 0, 0, 0)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
const isDark = mode == "dark"
const col = (i: number) => {

View file

@ -1465,6 +1465,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
streaming={true}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
@ -1667,6 +1669,7 @@ function InlineTool(props: {
const denied = createMemo(
() =>
error()?.includes("QuestionRejectedError") ||
error()?.includes("rejected permission") ||
error()?.includes("specified a rule") ||
error()?.includes("user dismissed"),

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