Compare commits

..

10 Commits

Author SHA1 Message Date
Aiden Cline
fab930e9f0 nit: cleanup again 2026-03-12 00:22:30 -05:00
Aiden Cline
3b74860067 Merge branch 'dev' into improve-azure 2026-03-12 00:20:32 -05:00
Aiden Cline
f1e058801c nit: use more idiomatic line 2026-03-12 00:15:48 -05:00
Aiden Cline
f912a4bddb cleanup 2026-03-11 23:42:41 -05:00
Aiden Cline
d4628788c7 cleanup 2026-03-11 23:41:43 -05:00
Aiden Cline
39353b58f1 sync 2026-03-11 22:56:53 -05:00
Aiden Cline
5ca53a8145 minor cleanup 2026-03-11 12:55:05 -05:00
Aiden Cline
a4efdb8825 checkpoint 2026-03-11 11:51:25 -05:00
Aiden Cline
5dcd374df7 Merge branch 'dev' into improve-azure 2026-03-10 22:46:05 -05:00
Aiden Cline
9c657f703b tweak: adjust getModel logic to be more reliable 2026-03-10 22:36:28 -05:00
113 changed files with 2442 additions and 3669 deletions

View File

@@ -1,6 +1,4 @@
node_modules
plans
package.json
plans/
bun.lock
.gitignore
package.json
package-lock.json

View File

@@ -1,5 +1,6 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -23,16 +24,7 @@ interface PR {
}
export default tool({
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,5 +1,6 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
@@ -39,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -0,0 +1,6 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

View File

@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences::
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.

1578
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-3D8mQQxibgfWfN1x4fSSxE+o/bRPE9DGEjOmhuC1RAY=",
"aarch64-linux": "sha256-PbbYSSTLmCrYa+wmh+63d/26ivNMPPfhjdiD02GrQ7U=",
"aarch64-darwin": "sha256-m/XLhseghXL6V7ovzx08bEqVdCdV0w20KcoJ6DzHhi8=",
"x86_64-darwin": "sha256-t1m+hMKnL+ygJZT58IuxiaFapEzD3yjfGJKmOCH9J3A="
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
}
}

View File

@@ -41,9 +41,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.29",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -70,8 +70,6 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
@@ -169,13 +167,6 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
## Writing New Tests
1. Choose appropriate folder or create new one

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
@@ -16,7 +15,6 @@ import {
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
@@ -30,53 +28,6 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)

View File

@@ -1,5 +1,4 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -92,14 +91,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({

View File

@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -14,7 +13,7 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await prompt.fill("/terminal")
await expect(slash).toBeVisible()
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
// which can steal focus from the prompt and prevent fill() from triggering

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()

View File

@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -14,7 +13,8 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey)
}
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals.first().locator("textarea")).toHaveCount(1)
})

View File

@@ -1,5 +1,4 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
@@ -18,7 +17,16 @@ async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
}
async function store(page: Page, key: string) {
@@ -48,16 +56,15 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
await gotoSession()
await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one })
await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two })
await run(page, `echo ${two}`)
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
@@ -69,7 +76,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two),
}
},
{ timeout: 5_000 },
{ timeout: 30_000 },
)
.toEqual({ first: false, second: true })
@@ -86,7 +93,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two),
}
},
{ timeout: 5_000 },
{ timeout: 30_000 },
)
.toEqual({ first: true, second: false })
})

View File

@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -14,5 +13,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
}
await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
})

View File

@@ -45,8 +45,8 @@
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
@@ -56,7 +56,6 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "4.0.0-beta.29",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -1,29 +1,14 @@
import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
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 { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type Duration, Effect } from "effect"
import {
type Component,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -37,13 +22,13 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -77,9 +62,6 @@ declare global {
deepLinks?: string[]
wsl?: boolean
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
}
}
@@ -133,11 +115,7 @@ export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<ThemeProvider>
<LanguageProvider>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
@@ -154,108 +132,15 @@ export function AppBaseProviders(props: ParentProps) {
)
}
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
return (
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
</p>
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">Other servers</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
@@ -264,7 +149,7 @@ export function AppInterface(props: {
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
@@ -279,7 +164,7 @@ export function AppInterface(props: {
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ConnectionGate>
</ServerKey>
</ServerProvider>
)
}

View File

@@ -14,9 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface ServerFormProps {
value: string
@@ -43,15 +41,13 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showRequestError(language, err)
return null
@@ -60,22 +56,20 @@ function useDefaultServer() {
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey, canDefault, setDefault }
return { defaultUrl, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
function useServerPreview(fetcher: typeof fetch) {
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
@@ -98,7 +92,7 @@ function useServerPreview() {
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy)
}
@@ -176,15 +170,15 @@ export function DialogSelectServer() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
username: "",
password: "",
adding: false,
error: "",
@@ -207,7 +201,7 @@ export function DialogSelectServer() {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
username: "",
password: "",
adding: false,
error: "",
@@ -270,7 +264,7 @@ export function DialogSelectServer() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -368,9 +362,9 @@ export function DialogSelectServer() {
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
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)
const result = await checkServerHealth(conn.http, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -410,7 +404,7 @@ export function DialogSelectServer() {
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
const result = await checkServerHealth(conn.http, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -447,7 +441,7 @@ export function DialogSelectServer() {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
username: "",
password: "",
error: "",
status: undefined,
@@ -500,8 +494,8 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
platform.setDefaultServer?.(null)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
}
}
@@ -557,7 +551,7 @@ export function DialogSelectServer() {
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -590,14 +584,14 @@ export function DialogSelectServer() {
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}

View File

@@ -1,81 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
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 { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -53,8 +53,7 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const checkServerHealth = useCheckServerHealth()
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
@@ -65,7 +64,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
if (dead) return
@@ -169,6 +168,7 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -176,10 +176,10 @@ 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, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)

View File

@@ -10,7 +10,6 @@ import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { terminalAttr, terminalProbe } from "@/testing/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -161,7 +160,6 @@ export const Terminal = (props: TerminalProps) => {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
const probe = terminalProbe(id)
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -328,9 +326,6 @@ export const Terminal = (props: TerminalProps) => {
}
onMount(() => {
probe.init()
cleanups.push(() => probe.drop())
const run = async () => {
const loaded = await loadGhostty()
if (disposed) return
@@ -358,13 +353,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data, done) =>
t.write(data, () => {
probe.render(data)
probe.settle()
done?.()
}),
)
output = terminalWriter((data, done) => t.write(data, done))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -452,6 +441,10 @@ export const Terminal = (props: TerminalProps) => {
startResize()
}
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const once = { value: false }
let closing = false
@@ -459,7 +452,7 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
@@ -467,7 +460,6 @@ export const Terminal = (props: TerminalProps) => {
ws = socket
const handleOpen = () => {
probe.connect()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
@@ -568,7 +560,6 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
{...{ [terminalAttr]: id }}
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background }}

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -266,9 +266,6 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
@@ -285,7 +282,7 @@ export function Titlebar() {
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="w-36 shrink-0" />}
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>

View File

@@ -1,7 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -59,10 +58,10 @@ export type Platform = {
fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */
getDefaultServer?(): Promise<ServerConnection.Key | null>
getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>

View File

@@ -1,8 +1,9 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -95,7 +96,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const checkServerHealth = useCheckServerHealth()
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
@@ -196,7 +197,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => {
const current_ = current()

View File

@@ -98,19 +98,6 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
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"}`
return location.origin
}
const getDefaultUrl = () => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
return getCurrentUrl()
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -119,20 +106,26 @@ const platform: Platform = {
forward,
restart,
notify,
getDefaultServer: async () => {
const stored = readDefaultServerUrl()
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
getDefaultServerUrl: async () => readDefaultServerUrl(),
setDefaultServerUrl: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
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"}`
return location.origin
})
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -26,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -37,7 +35,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -46,8 +43,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -262,21 +257,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress" class="ml-auto mr-1">
<div data-slot="question-progress">
<For each={questions()}>
{(_, i) => (
<button
@@ -288,38 +271,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
</>
}
footer={
<>
@@ -339,121 +297,56 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-custom="true"
data-picked={on()}
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
aria-checked={picked()}
disabled={store.sending}
onClick={customOpen}
onClick={() => selectOption(i())}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
}
>
<form
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
disabled={store.sending}
onClick={customOpen}
>
<span
data-slot="question-option-check"
@@ -472,39 +365,80 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</form>
</Show>
</div>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</DockPrompt>
)

View File

@@ -1,64 +0,0 @@
export const terminalAttr = "data-pty-id"
export type TerminalProbeState = {
connected: boolean
rendered: string
settled: number
}
export type E2EWindow = Window & {
__opencode_e2e?: {
terminal?: {
enabled?: boolean
terminals?: Record<string, TerminalProbeState>
}
}
}
const seed = (): TerminalProbeState => ({
connected: false,
rendered: "",
settled: 0,
})
const root = () => {
if (typeof window === "undefined") return
const state = (window as E2EWindow).__opencode_e2e?.terminal
if (!state?.enabled) return
state.terminals ??= {}
return state.terminals
}
export const terminalProbe = (id: string) => {
const set = (next: Partial<TerminalProbeState>) => {
const terms = root()
if (!terms) return
terms[id] = { ...(terms[id] ?? seed()), ...next }
}
return {
init() {
set(seed())
},
connect() {
set({ connected: true })
},
render(data: string) {
const terms = root()
if (!terms) return
const prev = terms[id] ?? seed()
terms[id] = { ...prev, rendered: prev.rendered + data }
},
settle() {
const terms = root()
if (!terms) return
const prev = terms[id] ?? seed()
terms[id] = { ...prev, settled: prev.settled + 1 }
},
drop() {
const terms = root()
if (!terms) return
delete terms[id]
},
}
}

View File

@@ -1,4 +1,3 @@
import { usePlatform } from "@/context/platform"
import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
@@ -82,10 +81,3 @@ export async function checkServerHealth(
.catch((error) => next(count, error))
return attempt(0).finally(() => timeout?.clear?.())
}
export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
}

View File

@@ -30,7 +30,6 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "4.0.0-beta.29",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",

View File

@@ -31,13 +31,35 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import {
checkHealth,
checkHealthOrAskRetry,
getDefaultServerUrl,
getSavedServerUrl,
getWslConfig,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
} from "./server"
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
type ServerConnection =
| { variant: "existing"; url: string }
| {
variant: "cli"
url: string
password: null | string
health: {
wait: Promise<void>
}
events: any
}
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
const loadingWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null
const loadingComplete = defer<void>()
@@ -109,48 +131,77 @@ function setInitStep(step: InitStep) {
initEmitter.emit("step", step)
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
async function setupServerConnection(): Promise<ServerConnection> {
const customUrl = await getSavedServerUrl()
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
serverReady.resolve({ url: customUrl, password: null })
return { variant: "existing", url: customUrl }
}
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
const localUrl = `http://${hostname}:${port}`
logger.log("spawning sidecar", { url })
if (await checkHealth(localUrl)) {
serverReady.resolve({ url: localUrl, password: null })
return { variant: "existing", url: localUrl }
}
const password = randomUUID()
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
serverReady.resolve({
url,
username: "opencode",
return {
variant: "cli",
url: localUrl,
password,
})
health,
events,
}
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
logger.log("setting up server connection")
const serverConnection = await setupServerConnection()
logger.log("server connection ready", {
variant: serverConnection.variant,
url: serverConnection.url,
})
if (needsMigration) {
await sqliteDone?.promise
const cliHealthCheck = (() => {
if (serverConnection.variant == "cli") {
return async () => {
const { events, health } = serverConnection
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
await health.wait
serverReady.resolve({
url: serverConnection.url,
password: serverConnection.password,
})
}
} else {
serverReady.resolve({ url: serverConnection.url, password: null })
return null
}
})()
logger.log("server connection started")
if (cliHealthCheck) {
if (needsMigration) await sqliteDone?.promise
cliHealthCheck?.()
}
await Promise.race([
health.wait,
delay(30_000).then(() => {
throw new Error("Sidecar health check timed out")
}),
]).catch((error) => {
logger.error("sidecar health check failed", error)
})
logger.log("loading task finished")
})()
@@ -160,26 +211,32 @@ async function initialize() {
deepLinks: pendingDeepLinks,
}
wireMenu()
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow(globals)
await delay(1_000)
const loadingWindow = await (async () => {
if (needsMigration /** TOOD: 1 second timeout */) {
// showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
const loadingWindow = createLoadingWindow(globals)
await delay(1000)
return loadingWindow
} else {
logger.log("showing main window without loading window")
mainWindow = createMainWindow(globals)
wireMenu()
}
}
})()
await loadingTask
setInitStep({ phase: "done" })
if (overlay) {
if (loadingWindow) {
await loadingComplete.promise
}
mainWindow = createMainWindow(globals)
if (!mainWindow) {
mainWindow = createMainWindow(globals)
wireMenu()
}
overlay?.close()
loadingWindow?.close()
}
function wireMenu() {

View File

@@ -2,9 +2,8 @@ import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
type Deps = {
killSidecar: () => void
@@ -162,11 +161,6 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
}
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {

View File

@@ -1,4 +1,6 @@
import { serve, type CommandChild } from "./cli"
import { dialog } from "electron"
import { getConfig, serve, type CommandChild, type Config } from "./cli"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -29,6 +31,15 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export async function getSavedServerUrl(): Promise<string | null> {
const direct = getDefaultServerUrl()
if (direct) return direct
const config = await getConfig().catch(() => null)
if (!config) return null
return getServerUrlFromConfig(config)
}
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
@@ -83,4 +94,36 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
}
export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
while (true) {
if (await checkHealth(url)) return true
const result = await dialog.showMessageBox({
type: "warning",
message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
title: "Connection Failed",
buttons: ["Retry", "Start Local"],
defaultId: 0,
cancelId: 1,
})
if (result.response === 0) continue
return false
}
}
export function normalizeHostnameForUrl(hostname: string) {
if (hostname === "0.0.0.0") return "127.0.0.1"
if (hostname === "::") return "[::1]"
if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
return hostname
}
export function getServerUrlFromConfig(config: Config) {
const server = config.server
if (!server?.port) return null
const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
return `http://${host}:${server.port}`
}
export type { CommandChild }

View File

@@ -1,8 +1,7 @@
import windowState from "electron-window-state"
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { app, BrowserWindow, nativeImage } from "electron"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import type { TitlebarTheme } from "../preload/types"
type Globals = {
updaterEnabled: boolean
@@ -21,24 +20,6 @@ function iconPath() {
return join(iconsDir(), `icon.${ext}`)
}
function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function overlay(theme: Partial<TitlebarTheme> = {}) {
const mode = theme.mode ?? tone()
return {
color: "#00000000",
symbolColor: mode === "dark" ? "white" : "black",
height: 40,
}
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(theme))
}
export function setDockIcon() {
if (process.platform !== "darwin") return
app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png")))
@@ -50,7 +31,6 @@ export function createMainWindow(globals: Globals) {
defaultHeight: 800,
})
const mode = tone()
const win = new BrowserWindow({
x: state.x,
y: state.y,
@@ -69,7 +49,11 @@ export function createMainWindow(globals: Globals) {
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
titleBarOverlay: {
color: "transparent",
symbolColor: "#999",
height: 40,
},
}
: {}),
webPreferences: {
@@ -87,7 +71,6 @@ export function createMainWindow(globals: Globals) {
}
export function createLoadingWindow(globals: Globals) {
const mode = tone()
const win = new BrowserWindow({
width: 640,
height: 480,
@@ -100,7 +83,11 @@ export function createLoadingWindow(globals: Globals) {
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
titleBarOverlay: {
color: "transparent",
symbolColor: "#999",
height: 40,
},
}
: {}),
webPreferences: {

View File

@@ -57,7 +57,6 @@ const api: ElectronAPI = {
relaunch: () => ipcRenderer.send("relaunch"),
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),

View File

@@ -2,7 +2,6 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" }
export type ServerReadyData = {
url: string
username: string | null
password: string | null
}
@@ -11,9 +10,6 @@ export type SqliteMigrationProgress = { type: "InProgress"; value: number } | {
export type WslConfig = { enabled: boolean }
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
}
export type ElectronAPI = {
killSidecar: () => Promise<void>
@@ -61,7 +57,6 @@ export type ElectronAPI = {
relaunch: () => void
getZoomFactor: () => Promise<number>
setZoomFactor: (factor: number) => Promise<void>
setTitlebar: (theme: TitlebarTheme) => Promise<void>
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>

View File

@@ -9,8 +9,9 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import { Splash } from "@opencode-ai/ui/logo"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createResource, onCleanup, onMount, Show } from "solid-js"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import pkg from "../../package.json"
@@ -18,6 +19,7 @@ import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import type { ServerReadyData } from "../preload/types"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -196,13 +198,11 @@ const createPlatform = (): Platform => {
await window.api.setWslConfig({ enabled })
},
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
if (!url) return null
return ServerConnection.Key.make(url)
getDefaultServerUrl: async () => {
return window.api.getDefaultServerUrl().catch(() => null)
},
setDefaultServer: async (url: string | null) => {
setDefaultServerUrl: async (url: string | null) => {
await window.api.setDefaultServerUrl(url)
},
@@ -240,31 +240,6 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const servers = () => {
const data = sidecar()
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
}
return [server] as ServerConnection.Any[]
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
@@ -273,12 +248,6 @@ render(() => {
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -289,20 +258,55 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading}>
{(_) => {
<ServerGate>
{(data) => {
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} router={MemoryRouter}>
<Inner />
</AppInterface>
)
}}
</Show>
</ServerGate>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined))
console.log({ serverData })
if (serverData.state === "errored") throw serverData.error
return (
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
{(data) => props.children(data)}
</Show>
)
}

View File

@@ -1,5 +1,5 @@
import { MetaProvider } from "@solidjs/meta"
import { render } from "solid-js/web"
import { MetaProvider } from "@solidjs/meta"
import "@opencode-ai/app/index.css"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
@@ -34,10 +34,7 @@ render(() => {
const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
if (progress.type === "Done") {
setPercent(100)
setStep({ phase: "done" })
}
if (progress.type === "Done") setPercent(100)
})
onCleanup(() => {

View File

@@ -12,10 +12,12 @@ mod window_customizer;
mod windows;
use crate::cli::CommandChild;
use futures::{FutureExt, TryFutureExt};
use futures::{
FutureExt, TryFutureExt,
future::{self, Shared},
};
use std::{
env,
future::Future,
net::TcpListener,
path::PathBuf,
process::Command,
@@ -33,6 +35,7 @@ use tokio::{
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
use crate::constants::*;
use crate::server::get_saved_server_url;
use crate::windows::{LoadingWindow, MainWindow};
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
@@ -40,6 +43,7 @@ struct ServerReadyData {
url: String,
username: Option<String>,
password: Option<String>,
is_sidecar: bool,
}
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -61,12 +65,27 @@ struct InitState {
current: watch::Receiver<InitStep>,
}
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
}
/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
status,
}
}
pub fn set_child(&self, child: Option<CommandChild>) {
*self.child.lock().unwrap() = child;
}
}
#[tauri::command]
#[specta::specta]
@@ -91,21 +110,26 @@ fn kill_sidecar(app: AppHandle) {
tracing::info!("Killed server");
}
fn get_logs() -> String {
logging::tail()
}
#[tauri::command]
#[specta::specta]
async fn await_initialization(
state: State<'_, SidecarReady>,
state: State<'_, ServerState>,
init_state: State<'_, InitState>,
events: Channel<InitStep>,
) -> Result<ServerReadyData, String> {
let mut rx = init_state.current.clone();
let stream = async {
let events = async {
let e = *rx.borrow();
let _ = events.send(e);
while rx.changed().await.is_ok() {
let step = *rx.borrow_and_update();
let _ = events.send(step);
if matches!(step, InitStep::Done) {
@@ -114,18 +138,10 @@ async fn await_initialization(
}
};
// Wait for sidecar credentials (available immediately after spawn, before health check)
let data = async {
state
.inner()
.0
.clone()
.await
.map_err(|_| "Failed to get sidecar data".to_string())
};
let (result, _) = futures::future::join(data, stream).await;
result
future::join(state.status.clone(), events)
.await
.0
.map_err(|_| "Failed to get server status".to_string())?
}
#[tauri::command]
@@ -423,35 +439,22 @@ async fn initialize(app: AppHandle) {
setup_app(&app, init_rx);
spawn_cli_sync_task(app.clone());
// Spawn sidecar immediately - credentials are known before health check
let port = get_sidecar_port();
let hostname = "127.0.0.1";
let url = format!("http://{hostname}:{port}");
let password = uuid::Uuid::new_v4().to_string();
tracing::info!("Spawning sidecar on {url}");
let (child, health_check) =
server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
// Make sidecar credentials available immediately (before health check completes)
let (ready_tx, ready_rx) = oneshot::channel();
let _ = ready_tx.send(ServerReadyData {
url: url.clone(),
username: Some("opencode".to_string()),
password: Some(password),
});
app.manage(SidecarReady(ready_rx.shared()));
app.manage(ServerState {
child: Arc::new(Mutex::new(Some(child))),
});
let (server_ready_tx, server_ready_rx) = oneshot::channel();
let server_ready_rx = server_ready_rx.shared();
app.manage(ServerState::new(None, server_ready_rx.clone()));
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
tracing::info!("Main and loading windows created");
// SQLite migration handling:
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
// A separate loading window is shown for long migrations.
let needs_migration = !sqlite_file_exists();
let sqlite_done = needs_migration.then(|| {
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
// First, we spawn a task that listens for SqliteMigrationProgress events that can
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout.
let needs_sqlite_migration = !sqlite_file_exists();
let sqlite_done = needs_sqlite_migration.then(|| {
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
"Sqlite file not found, waiting for it to be generated"
@@ -477,22 +480,80 @@ async fn initialize(app: AppHandle) {
}))
});
// The loading task waits for SQLite migration (if needed) then for the sidecar health check.
// This is only used to drive the loading window progress - the main window is shown immediately.
let loading_task = tokio::spawn({
let app = app.clone();
async move {
if let Some(sqlite_done_rx) = sqlite_done {
let _ = sqlite_done_rx.await;
tracing::info!("Setting up server connection");
let server_connection = setup_server_connection(app.clone()).await;
tracing::info!("Server connection setup");
// we delay spawning this future so that the timeout is created lazily
let cli_health_check = match server_connection {
ServerConnection::CLI {
child,
health_check,
url,
username,
password,
} => {
let app = app.clone();
Some(
async move {
let res = timeout(Duration::from_secs(30), health_check.0).await;
let err = match res {
Ok(Ok(Ok(()))) => None,
Ok(Ok(Err(e))) => Some(e),
Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
Err(_) => Some("Health check timed out".to_string()),
};
if let Some(err) = err {
let _ = child.kill();
return Err(format!(
"Failed to spawn OpenCode Server ({err}). Logs:\n{}",
get_logs()
));
}
tracing::info!("CLI health check OK");
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData {
url,
username,
password,
is_sidecar: true,
})
}
.map(move |res| {
let _ = server_ready_tx.send(res);
}),
)
}
ServerConnection::Existing { url } => {
let _ = server_ready_tx.send(Ok(ServerReadyData {
url: url.to_string(),
username: None,
password: None,
is_sidecar: false,
}));
None
}
};
tracing::info!("server connection started");
if let Some(cli_health_check) = cli_health_check {
if let Some(sqlite_done_rx) = sqlite_done {
let _ = sqlite_done_rx.await;
}
tokio::spawn(cli_health_check);
}
// Wait for sidecar to become healthy (for loading window progress)
let res = timeout(Duration::from_secs(30), health_check.0).await;
match res {
Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
Err(_) => tracing::error!("Sidecar health check timed out"),
}
let _ = server_ready_rx.await;
tracing::info!("Loading task finished");
}
@@ -500,8 +561,7 @@ async fn initialize(app: AppHandle) {
.map_err(|_| ())
.shared();
// Show loading window for SQLite migrations if they take >1s
let loading_window = if needs_migration
let loading_window = if needs_sqlite_migration
&& timeout(Duration::from_secs(1), loading_task.clone())
.await
.is_err()
@@ -511,12 +571,12 @@ async fn initialize(app: AppHandle) {
sleep(Duration::from_secs(1)).await;
Some(loading_window)
} else {
tracing::debug!("Showing main window without loading window");
MainWindow::create(&app).expect("Failed to create main window");
None
};
// Create main window immediately - the web app handles its own loading/health gate
MainWindow::create(&app).expect("Failed to create main window");
let _ = loading_task.await;
tracing::info!("Loading done, completing initialisation");
@@ -524,9 +584,12 @@ async fn initialize(app: AppHandle) {
if loading_window.is_some() {
loading_window_complete.await;
tracing::info!("Loading window completed");
}
MainWindow::create(&app).expect("Failed to create main window");
if let Some(loading_window) = loading_window {
let _ = loading_window.close();
}
@@ -547,6 +610,59 @@ fn spawn_cli_sync_task(app: AppHandle) {
});
}
enum ServerConnection {
Existing {
url: String,
},
CLI {
url: String,
username: Option<String>,
password: Option<String>,
child: CommandChild,
health_check: server::HealthCheck,
},
}
async fn setup_server_connection(app: AppHandle) -> ServerConnection {
let custom_url = get_saved_server_url(&app).await;
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = &custom_url
&& server::check_health_or_ask_retry(&app, url).await
{
tracing::info!(%url, "Connected to custom server");
// If the default server is already local, no need to also spawn a sidecar
if server::is_localhost_url(url) {
return ServerConnection::Existing { url: url.clone() };
}
// Remote default server: fall through and also spawn a local sidecar
}
let local_port = get_sidecar_port();
let hostname = "127.0.0.1";
let local_url = format!("http://{hostname}:{local_port}");
tracing::debug!(url = %local_url, "Checking health of local server");
if server::check_health(&local_url, None).await {
tracing::info!(url = %local_url, "Health check OK, using existing server");
return ServerConnection::Existing { url: local_url };
}
let password = uuid::Uuid::new_v4().to_string();
tracing::info!("Spawning new local server");
let (child, health_check) =
server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
ServerConnection::CLI {
url: local_url,
username: Some("opencode".to_string()),
password: Some(password),
child,
health_check,
}
}
fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT")

View File

@@ -1,6 +1,7 @@
use std::time::{Duration, Instant};
use tauri::AppHandle;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_store::StoreExt;
use tokio::task::JoinHandle;
@@ -84,6 +85,22 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
Ok(())
}
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
tracing::info!(%url, "Using desktop-specific custom URL");
return Some(url);
}
if let Some(cli_config) = cli::get_config(app).await
&& let Some(url) = get_server_url_from_config(&cli_config)
{
tracing::info!(%url, "Using custom server URL from config");
return Some(url);
}
None
}
pub fn spawn_local_server(
app: AppHandle,
hostname: String,
@@ -128,27 +145,19 @@ pub fn spawn_local_server(
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
async fn check_health(url: &str, password: Option<&str>) -> bool {
pub async fn check_health(url: &str, password: Option<&str>) -> bool {
let Ok(url) = reqwest::Url::parse(url) else {
return false;
};
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
if url
.host_str()
.is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
{
if url_is_localhost(&url) {
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
// excluding loopback. reqwest respects these by default, which can prevent the desktop
// app from reaching its own local sidecar server.
builder = builder.no_proxy();
}
};
let Ok(client) = builder.build() else {
return false;
@@ -168,3 +177,77 @@ async fn check_health(url: &str, password: Option<&str>) -> bool {
.map(|r| r.status().is_success())
.unwrap_or(false)
}
pub fn is_localhost_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
}
fn url_is_localhost(url: &reqwest::Url) -> bool {
url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
}
/// Converts a bind address hostname to a valid URL hostname for connection.
/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
fn normalize_hostname_for_url(hostname: &str) -> String {
// Wildcard bind addresses -> localhost equivalents
if hostname == "0.0.0.0" {
return "127.0.0.1".to_string();
}
if hostname == "::" {
return "[::1]".to_string();
}
// IPv6 addresses need brackets in URLs
if hostname.contains(':') && !hostname.starts_with('[') {
return format!("[{}]", hostname);
}
hostname.to_string()
}
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
let server = config.server.as_ref()?;
let port = server.port?;
tracing::debug!(port, "server.port found in OC config");
let hostname = server
.hostname
.as_ref()
.map(|v| normalize_hostname_for_url(v))
.unwrap_or_else(|| "127.0.0.1".to_string());
Some(format!("http://{}:{}", hostname, port))
}
pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
tracing::debug!(%url, "Checking health");
loop {
if check_health(url, None).await {
return true;
}
const RETRY: &str = "Retry";
let res = app.dialog()
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
.title("Connection Failed")
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
.blocking_show_with_result();
match res {
MessageDialogResult::Custom(name) if name == RETRY => {
continue;
}
_ => {
break;
}
}
}
false
}

View File

@@ -38,6 +38,7 @@ export type ServerReadyData = {
url: string,
username: string | null,
password: string | null,
is_sidecar: boolean,
};
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

View File

@@ -9,6 +9,7 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import { Splash } from "@opencode-ai/ui/logo"
import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
@@ -21,7 +22,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { createResource, onCleanup, onMount, Show } from "solid-js"
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
@@ -29,7 +30,7 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { commands, ServerReadyData, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -347,13 +348,12 @@ const createPlatform = (): Platform => {
await commands.setWslConfig({ enabled })
},
getDefaultServer: async () => {
const url = await commands.getDefaultServerUrl().catch(() => null)
if (!url) return null
return ServerConnection.Key.make(url)
getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
},
setDefaultServer: async (url: string | null) => {
setDefaultServerUrl: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
@@ -412,33 +412,12 @@ void listenForDeepLinks()
render(() => {
const platform = createPlatform()
// Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
platform.getDefaultServerUrl?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
// Build the sidecar server connection once credentials arrive
const servers = () => {
const data = sidecar()
if (!data) return []
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Sidecar = {
displayName: t("desktop.server.local"),
type: "sidecar",
variant: "base",
http,
}
return [server] as ServerConnection.Any[]
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
@@ -447,12 +426,6 @@ render(() => {
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -463,19 +436,60 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading}>
{(_) => {
<ServerGate>
{(data) => {
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Any = data.is_sidecar
? {
displayName: t("desktop.server.local"),
type: "sidecar",
variant: "base",
http,
}
: { type: "http", http }
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
>
<Inner />
</AppInterface>
<Show when={!defaultServer.loading}>
<AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
<Inner />
</AppInterface>
</Show>
)
}}
</Show>
</ServerGate>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
if (serverData.state === "errored") throw serverData.error
return (
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
}
>
{(data) => props.children(data())}
</Show>
)
}

View File

@@ -7,7 +7,7 @@
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000 registry",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -25,15 +25,9 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -49,14 +43,13 @@
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -89,12 +82,9 @@
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -103,8 +93,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -119,7 +109,8 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -63,7 +63,6 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -1,5 +1,13 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
@@ -37,4 +45,87 @@ export namespace BunProc {
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,3 +1,4 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
@@ -27,4 +28,17 @@ export namespace PackageRegistry {
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
}

View File

@@ -11,11 +11,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
@@ -104,10 +99,11 @@ const switchEffect = Effect.fn("switch")(function* () {
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
@@ -136,10 +132,11 @@ const orgsEffect = Effect.fn("orgs")(function* () {
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === 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

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const server = Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -370,11 +370,6 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -480,7 +480,6 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -556,9 +555,8 @@ function App() {
category: "System",
},
{
title: mode() === "dark" ? "Light mode" : "Dark mode",
title: "Toggle appearance",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -597,7 +595,6 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -607,7 +604,6 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -648,7 +644,6 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -664,7 +659,6 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -674,7 +668,6 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

@@ -7,27 +7,6 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -42,7 +21,6 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -94,8 +72,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) => {
const items = pipe(
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -126,9 +104,8 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
)
return items
}),
),
),
)
const popularProviders = !connected()
@@ -177,13 +154,6 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -9,7 +9,6 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
@@ -57,7 +56,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
await Bun.sleep(1000)
continue
}
if (!result.data) {

View File

@@ -78,7 +78,6 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -172,17 +171,6 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -1024,30 +1012,23 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</Show>
</box>
</box>

View File

@@ -25,7 +25,6 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -107,8 +106,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
@@ -139,13 +136,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -461,7 +451,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

@@ -47,7 +47,6 @@ export function Home() {
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
search: "toggle tips",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {

View File

@@ -568,7 +568,6 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -583,7 +582,6 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -594,7 +592,6 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -608,7 +605,6 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -623,7 +619,6 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -632,9 +627,8 @@ export function Session() {
},
},
{
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -913,12 +907,12 @@ export function Session() {
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)
await Filesystem.write(filepath, transcript)
await Bun.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Filesystem.write(filepath, result)
await Bun.write(filepath, result)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })

View File

@@ -34,7 +34,6 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
@@ -86,8 +85,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
})
.map((x) => x.obj)

View File

@@ -8,6 +8,7 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
@@ -37,7 +38,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
let server: Bun.Server<BunWebSocketData> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
@@ -119,7 +120,7 @@ export const rpc = {
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = await Server.listen(input)
server = Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
@@ -142,7 +143,7 @@ export const rpc = {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) await server.stop(true)
if (server) server.stop(true)
},
}

View File

@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
const server = Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

View File

@@ -1,6 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
import { pathToFileURL, fileURLToPath } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
@@ -22,6 +22,7 @@ import {
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -29,11 +30,12 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Npm } from "@/npm"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -150,7 +152,8 @@ export namespace Config {
deps.push(
iife(async () => {
await installDependencies(dir)
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
}),
)
@@ -266,10 +269,6 @@ export namespace Config {
}
export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -283,15 +282,22 @@ export namespace Config {
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
if (!(await Filesystem.exists(gitignore)))
await Filesystem.write(
gitignore,
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await Npm.install(dir)
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
}
async function isWritable(dir: string) {
@@ -303,6 +309,41 @@ export namespace Config {
}
}
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -754,7 +795,6 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -795,12 +835,7 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),

View File

@@ -1,4 +1,3 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -57,24 +56,10 @@ export namespace WorkspaceServer {
}
export function Listen(opts: { hostname: string; port: number }) {
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
fetch: App().fetch,
})
}
}

View File

@@ -1,5 +1,4 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
@@ -118,7 +117,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
await sleep(1000)
await Bun.sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -128,7 +127,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
await sleep(250)
await Bun.sleep(250)
}
}

View File

@@ -1,40 +1,40 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<string[] | false>
enabled(): Promise<boolean>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
return which("gofmt") !== null
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
return which("mix") !== null
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -73,9 +73,8 @@ export const prettier: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
return [await Npm.which("prettier"), "--write", "$FILE"]
}
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
}
return false
},
@@ -83,6 +82,7 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -95,9 +95,8 @@ export const oxfmt: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
return [await Npm.which("oxfmt"), "$FILE"]
}
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
}
return false
},
@@ -105,6 +104,7 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
return true
}
}
return false
@@ -150,49 +150,47 @@ export const biome: Info = {
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
return which("zig") !== null
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
return items.length > 0
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
return which("ktlint") !== null
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
const p = which("ruff")
if (p === null) return false
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
if (content.includes("[tool.ruff]")) return true
} else {
return [p, "format", "$FILE"]
return true
}
}
}
@@ -201,7 +199,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return [p, "format", "$FILE"]
if (content.includes("ruff")) return true
}
}
return false
@@ -210,13 +208,14 @@ export const ruff: Info = {
export const rlang: Info = {
name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = which("air")
if (airPath == null) return false
try {
const proc = Process.spawn([airPath, "--help"], {
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
@@ -228,10 +227,7 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
return hasR && hasFormatter
} catch (error) {
return false
}
@@ -240,14 +236,14 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
const uvPath = which("uv")
if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
if (code === 0) return [uvPath, "format", "--", "$FILE"]
return code === 0
}
return false
},
@@ -255,118 +251,108 @@ export const uvformat: Info = {
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
return which("rubocop") !== null
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
return which("standardrb") !== null
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
return which("htmlbeautifier") !== null
},
}
export const dart: Info = {
name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
return which("dart") !== null
},
}
export const ocamlformat: Info = {
name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
const path = which("ocamlformat")
if (!path) return false
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
if (items.length === 0) return false
return [path, "-i", "$FILE"]
return items.length > 0
},
}
export const terraform: Info = {
name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
return which("terraform") !== null
},
}
export const latexindent: Info = {
name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
return which("latexindent") !== null
},
}
export const gleam: Info = {
name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
return which("gleam") !== null
},
}
export const shfmt: Info = {
name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
return which("shfmt") !== null
},
}
export const nixfmt: Info = {
name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
return which("nixfmt") !== null
},
}
export const rustfmt: Info = {
name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
return which("rustfmt") !== null
},
}
export const pint: Info = {
name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -375,9 +361,8 @@ export const pint: Info = {
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
return ["./vendor/bin/pint", "$FILE"]
}
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
}
return false
},
@@ -385,30 +370,27 @@ export const pint: Info = {
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
return which("ormolu") !== null
},
}
export const cljfmt: Info = {
name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
return which("cljfmt") !== null
},
}
export const dfmt: Info = {
name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
return which("dfmt") !== null
},
}

View File

@@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status>
const state = Instance.state(async () => {
const cache: Record<string, string[] | false> = {}
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
cache,
enabled,
formatters,
}
}
@@ -46,41 +46,43 @@ export namespace Format {
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
result.enabled = async () => item.command ?? false
if (result.command.length === 0) continue
result.enabled = async () => true
result.name = name
formatters[name] = result
}
return {
cache,
enabled,
formatters,
}
})
async function resolveCommand(item: Formatter.Info) {
async function isEnabled(item: Formatter.Info) {
const s = await state()
let command = s.cache[item.name]
if (command === undefined) {
log.info("resolving command", { name: item.name })
command = await item.enabled()
s.cache[item.name] = command
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
}
return command
return status
}
async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters)
const result: { info: Formatter.Info; command: string[] }[] = []
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
const command = await resolveCommand(item)
if (!command) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push({ info: item, command })
result.push(item)
}
return result
}
@@ -89,11 +91,11 @@ export namespace Format {
const s = await state()
const result: Status[] = []
for (const formatter of Object.values(s.formatters)) {
const command = await resolveCommand(formatter)
const enabled = await isEnabled(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: !!command,
enabled,
})
}
return result
@@ -106,27 +108,29 @@ export namespace Format {
log.info("formatting", { file })
const ext = path.extname(file)
for (const { info, command } of await getFormatter(ext)) {
const replaced = command.map((x) => x.replace("$FILE", file))
log.info("running", { replaced })
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(replaced, {
cwd: Instance.directory,
env: { ...process.env, ...info.environment },
stdout: "ignore",
stderr: "ignore",
})
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command,
...info.environment,
command: item.command,
...item.environment,
})
} catch (error) {
log.error("failed to format file", {
error,
command,
...info.environment,
command: item.command,
...item.environment,
file,
})
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(cache, "bin"),
bin: path.join(data, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -3,6 +3,7 @@ import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -12,7 +13,6 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { Npm } from "@/npm"
const spawn = ((cmd, args, opts) => {
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
@@ -107,7 +107,7 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root,
env: {
...process.env,
@@ -133,8 +133,29 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@vue/language-server")
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -197,7 +218,7 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
@@ -328,8 +349,8 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
bin = await Npm.which("biome")
args = ["lsp-proxy", "--stdio"]
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
@@ -355,7 +376,9 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls")
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -390,7 +413,9 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop")
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -495,8 +520,19 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("pyright")
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
}
args.push("--stdio")
@@ -598,7 +634,9 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls")
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const zig = which("zig")
@@ -708,7 +746,9 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls")
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -745,7 +785,9 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete")
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1011,8 +1053,22 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("svelte-language-server")
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1044,8 +1100,22 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@astrojs/language-server")
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1294,8 +1364,31 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("yaml-language-server")
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1324,7 +1417,9 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server")
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1460,8 +1555,22 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("intelephense")
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1543,8 +1652,22 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("bash-language-server")
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("start")
const proc = spawn(binary, args, {
@@ -1565,7 +1688,9 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls")
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1646,7 +1771,9 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab")
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1737,8 +1864,22 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("dockerfile-language-server-nodejs")
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1829,7 +1970,9 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist")
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return

View File

@@ -11,7 +11,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -167,10 +166,14 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && !pids.includes(cpid)) {
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
pids.push(cpid)
queue.push(cpid)
}

View File

@@ -1,5 +1,4 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -53,74 +52,11 @@ interface PendingAuth {
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof createServer> | undefined
let server: ReturnType<typeof Bun.serve> | undefined
const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> {
if (server) return
@@ -130,14 +66,75 @@ export namespace McpOAuthCallback {
return
}
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@@ -177,7 +174,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> {
if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve()))
server.stop()
server = undefined
log.info("oauth callback server stopped")
}

View File

@@ -1,8 +0,0 @@
import { Server } from "./server/server"
const result = await Server.listen({
port: 1338,
hostname: "0.0.0.0",
})
console.log(result)

View File

@@ -1,160 +0,0 @@
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
// tar silently swallows the error and skips writing files, leaving only empty
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
// flag. See tar's get-write-flag.js.
// Must be set before @npmcli/arborist is imported since tar caches the flag
// at module evaluation time — so we use a dynamic import() below.
if (process.platform === "win32") {
process.env.__FAKE_PLATFORM__ = "linux"
}
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Lock } from "../util/lock"
import { Log } from "../util/log"
import path from "path"
import { readdir } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
const { Arborist } = await import("@npmcli/arborist")
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
using _ = await Lock.write("npm-install")
log.info("installing package", {
pkg,
})
const hash = pkg
const dir = directory(hash)
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
log.info("package already installed", { pkg })
return first.path
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return first.path
}
export async function install(dir: string) {
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = path.join(directory(pkg), "node_modules", ".bin")
const files = await readdir(dir).catch(() => [])
if (!files.length) {
await add(pkg)
const retry = await readdir(dir).catch(() => [])
if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`)
return path.join(dir, retry[0])
}
return path.join(dir, files[0])
}
}

View File

@@ -5,7 +5,6 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
const log = Log.create({ service: "plugin.codex" })
@@ -241,7 +240,7 @@ interface PendingOAuth {
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof createServer> | undefined
let oauthServer: ReturnType<typeof Bun.serve> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
@@ -249,83 +248,77 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
oauthServer = Bun.serve({
port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
return new Response("Login cancelled", { status: 200 })
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
return new Response("Not found", { status: 404 })
},
})
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.close(() => {
log.info("codex oauth server stopped")
})
oauthServer.stop()
oauthServer = undefined
log.info("codex oauth server stopped")
}
}

View File

@@ -4,7 +4,7 @@ import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { Npm } from "../npm"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
@@ -27,9 +27,7 @@ export namespace Plugin {
directory: Instance.directory,
fetch: async (...args) => Server.Default().fetch(...args),
})
log.info("loading config")
const config = await Config.get()
log.info("config loaded")
const hooks: Hooks[] = []
const input: PluginInput = {
client,
@@ -39,8 +37,7 @@ export namespace Plugin {
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
$: Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
@@ -62,13 +59,16 @@ export namespace Plugin {
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
plugin = await Npm.add(plugin).catch((err) => {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { plugin, error: detail })
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${plugin}: ${detail}`,
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""

View File

@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { Npm } from "../npm"
import { BunProc } from "../bun"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
@@ -51,44 +51,10 @@ const DEFAULT_CHUNK_TIMEOUT = 120_000
export namespace Provider {
const log = Log.create({ service: "provider" })
function isGpt5OrLater(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) {
return false
}
return Number(match[1]) >= 5
}
function shouldUseCopilotResponsesApi(modelID: string): boolean {
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}
function googleVertexVars(options: Record<string, any>) {
const project =
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location =
options["location"] ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
return {
GOOGLE_VERTEX_PROJECT: project,
GOOGLE_VERTEX_LOCATION: location,
GOOGLE_VERTEX_ENDPOINT: endpoint,
}
}
function loadBaseURL(model: Model, options: Record<string, any>) {
const raw = options["baseURL"] ?? model.api.url
if (typeof raw !== "string") return raw
const vars = model.providerID === "google-vertex" ? googleVertexVars(options) : undefined
return raw.replace(/\$\{([^}]+)\}/g, (match, key) => {
const val = Env.get(String(key)) ?? vars?.[String(key) as keyof typeof vars]
return val ?? match
})
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) return false
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
@@ -165,12 +131,18 @@ export namespace Provider {
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
type CustomLoader = (provider: Info) => Promise<{
autoload: boolean
getModel?: CustomModelLoader
vars?: CustomVarsLoader
options?: Record<string, any>
}>
function useLanguageModel(sdk: any) {
return sdk.responses === undefined && sdk.chat === undefined
}
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic() {
return {
@@ -218,7 +190,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
@@ -228,16 +200,23 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}
},
azure: async () => {
azure: async (provider) => {
const resource = iife(() => {
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
return Env.get("AZURE_RESOURCE_NAME")
})
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
@@ -245,6 +224,11 @@ export namespace Provider {
}
},
options: {},
vars(_options) {
return {
...(resource && { AZURE_RESOURCE_NAME: resource }),
}
},
}
},
"azure-cognitive-services": async () => {
@@ -252,6 +236,7 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
@@ -440,17 +425,26 @@ export namespace Provider {
Env.get("GCP_PROJECT") ??
Env.get("GCLOUD_PROJECT")
const location =
const location = String(
provider.options?.location ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1",
)
const autoload = Boolean(project)
if (!autoload) return { autoload: false }
return {
autoload: true,
vars(_options: Record<string, any>) {
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
return {
...(project && { GOOGLE_VERTEX_PROJECT: project }),
GOOGLE_VERTEX_LOCATION: location,
GOOGLE_VERTEX_ENDPOINT: endpoint,
}
},
options: {
project,
location,
@@ -582,11 +576,15 @@ export namespace Provider {
autoload: !!apiKey,
options: {
apiKey,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
},
vars(_options) {
return {
CLOUDFLARE_ACCOUNT_ID: accountId,
}
},
}
},
"cloudflare-ai-gateway": async (input) => {
@@ -855,6 +853,9 @@ export namespace Provider {
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const varsLoaders: {
[providerID: string]: CustomVarsLoader
} = {}
const sdk = new Map<string, SDK>()
log.info("init")
@@ -1051,6 +1052,7 @@ export namespace Provider {
const result = await fn(data)
if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel
if (result.vars) varsLoaders[providerID] = result.vars
const opts = result.options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
mergeProvider(providerID, patch)
@@ -1112,6 +1114,7 @@ export namespace Provider {
providers,
sdk,
modelLoaders,
varsLoaders,
}
})
@@ -1136,7 +1139,30 @@ export namespace Provider {
options["includeUsage"] = true
}
const baseURL = loadBaseURL(model, options)
const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return
// some models/providers have variable urls, ex: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1"
// We track this in models.dev, and then when we are resolving the baseURL
// we need to string replace that literal: "${AZURE_RESOURCE_NAME}"
const loader = s.varsLoaders[model.providerID]
if (loader) {
const vars = loader(options)
for (const [key, value] of Object.entries(vars)) {
const field = "${" + key + "}"
url = url.replaceAll(field, value)
}
}
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
const val = Env.get(String(key))
return val ?? item
})
return url
})
if (baseURL !== undefined) options["baseURL"] = baseURL
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
@@ -1209,7 +1235,7 @@ export namespace Provider {
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
installedPath = await Npm.add(model.api.npm)
installedPath = await BunProc.install(model.api.npm, "latest")
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm

View File

@@ -23,8 +23,6 @@ export namespace Pty {
close: (code?: number, reason?: string) => void
}
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -99,9 +97,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [id, ws] of session.subscribers.entries()) {
for (const [key, ws] of session.subscribers.entries()) {
try {
if (key(ws) === id) ws.close()
if (ws.data === key) ws.close()
} catch {
// ignore
}
@@ -172,21 +170,21 @@ export namespace Pty {
ptyProcess.onData((chunk) => {
session.cursor += chunk.length
for (const [id, ws] of session.subscribers.entries()) {
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(id)
session.subscribers.delete(key)
continue
}
if (key(ws) !== id) {
session.subscribers.delete(id)
if (ws.data !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(id)
session.subscribers.delete(key)
}
}
@@ -228,9 +226,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [id, ws] of session.subscribers.entries()) {
for (const [key, ws] of session.subscribers.entries()) {
try {
if (key(ws) === id) ws.close()
if (ws.data === key) ws.close()
} catch {
// ignore
}
@@ -261,13 +259,16 @@ export namespace Pty {
}
log.info("client connected to session", { id })
const sub = key(ws)
// Use ws.data as the unique key for this connection lifecycle.
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
// Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
const cleanup = () => {
session.subscribers.delete(sub)
session.subscribers.delete(connectionKey)
}
const start = session.bufferCursor

View File

@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
async (c) => {
const projects = Project.list()
const projects = await Project.list()
return c.json(projects)
},
)

View File

@@ -1,14 +1,15 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
export const PtyRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
@@ -196,5 +197,5 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}
}),
)
}
),
)

View File

@@ -26,7 +26,7 @@ import { WorkspaceID } from "../control-plane/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
// import { PtyRoutes } from "./routes/pty"
import { PtyRoutes } from "./routes/pty"
import { McpRoutes } from "./routes/mcp"
import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config"
@@ -35,8 +35,7 @@ import { ProviderRoutes } from "./routes/provider"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
@@ -50,20 +49,13 @@ import { lazy } from "@/util/lazy"
globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
const log = Log.create({ service: "server" })
export const Default = lazy(() => create({}).app)
export const Default = lazy(() => createApp({}))
function create(opts: { cors?: string[] }) {
const log = Log.create({ service: "server" })
export const createApp = (opts: { cors?: string[] }): Hono => {
const app = new Hono()
const ws = createNodeWebSocket({ app })
const route = app
return app
.onError((err, c) => {
log.error("failed", {
error: err,
@@ -248,6 +240,7 @@ export namespace Server {
),
)
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
@@ -560,7 +553,6 @@ export namespace Server {
})
},
)
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
.all("/*", async (c) => {
const path = c.req.path
@@ -577,11 +569,6 @@ export namespace Server {
)
return response
})
return {
app: route as Hono,
ws,
}
}
export async function openapi() {
@@ -599,86 +586,52 @@ export namespace Server {
return result
}
export async function listen(opts: {
/** @deprecated do not use this dumb shit */
export let url: URL
export function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const log = Log.create({ service: "server" })
const built = create({
...opts,
})
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: built.app.fetch })
built.ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}) {
url = new URL(`http://${opts.hostname}:${opts.port}`)
const app = createApp(opts)
const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: app.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
try {
return Bun.serve({ ...args, port })
} catch {
return undefined
}
}
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(addr.port)
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const shouldPublishMDNS =
opts.mdns &&
addr.port &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(addr.port, opts.mdnsDomain)
MDNS.publish(server.port!, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: addr.port,
url,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
if (shouldPublishMDNS) MDNS.unpublish()
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
const originalStop = server.stop.bind(server)
server.stop = async (closeActiveConnections?: boolean) => {
if (shouldPublishMDNS) MDNS.unpublish()
return originalStop(closeActiveConnections)
}
return server
}
}

View File

@@ -8,9 +8,12 @@ import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { Database, eq, desc, inArray } from "@/storage/db"
import { MessageTable, PartTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import type { SystemError } from "bun"
import { type SystemError } from "bun"
import type { Provider } from "@/provider/provider"
export namespace MessageV2 {

View File

@@ -31,6 +31,7 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
@@ -45,7 +46,6 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { Process } from "@/util/process"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -316,7 +316,11 @@ export namespace SessionPrompt {
}
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (shouldExitLoop(lastUser, lastAssistant)) {
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
log.info("exiting loop", { sessionID })
break
}
@@ -1780,13 +1784,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments
}
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const sh = Shell.preferred()
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const results = await Promise.all(
shellMatches.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true })
return out.text
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
}),
)
let index = 0
@@ -1959,15 +1965,4 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return Session.setTitle({ sessionID: input.session.id, title })
}
}
/** @internal Exported for testing — determines whether the prompt loop should exit */
export function shouldExitLoop(
lastUser: MessageV2.User | undefined,
lastAssistant: MessageV2.Assistant | undefined,
): boolean {
if (!lastUser) return false
if (!lastAssistant?.finish) return false
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
return lastAssistant.parentID === lastUser.id
}
}

View File

@@ -1,8 +0,0 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
export function init(path: string) {
const sqlite = new Database(path, { create: true })
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,8 +0,0 @@
import { DatabaseSync } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
export function init(path: string) {
const sqlite = new DatabaseSync(path)
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,4 +1,5 @@
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
@@ -10,10 +11,10 @@ import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { init } from "#db"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
@@ -35,12 +36,17 @@ export namespace Database {
return path.join(Global.Path.data, `opencode-${safe}.db`)
})
export type Transaction = SQLiteTransaction<"sync", void>
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
}
function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0
@@ -77,14 +83,17 @@ export namespace Database {
export const Client = lazy(() => {
log.info("opening database", { path: Path })
const db = init(Path)
const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA cache_size = -64000")
db.run("PRAGMA foreign_keys = ON")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA synchronous = NORMAL")
sqlite.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite })
// Apply schema migrations
const entries =
@@ -108,11 +117,14 @@ export namespace Database {
})
export function close() {
Client().$client.close()
const sqlite = state.sqlite
if (!sqlite) return
sqlite.close()
state.sqlite = undefined
Client.reset()
}
export type TxOrDb = Transaction | Client
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
const ctx = Context.create<{
tx: TxOrDb

View File

@@ -45,7 +45,7 @@ export namespace ToolRegistry {
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
const mod = await import(pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}

View File

@@ -13,7 +13,6 @@ export namespace Process {
abort?: AbortSignal
kill?: NodeJS.Signals | number
timeout?: number
shell?: string
}
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
@@ -59,7 +58,6 @@ export namespace Process {
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",

View File

@@ -1,13 +1,9 @@
import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null

View File

@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("ask")
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "bash")).toBe("allow")
},
})
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
expect(build).toBeDefined()
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still asks (default behavior)
expect(evalPerm(build, "edit")).toBe("ask")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
},
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
describe("BunProc registry configuration", () => {
test("should not contain hardcoded registry parameters", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that no hardcoded registry is present
expect(content).not.toContain("--registry=")
expect(content).not.toContain("hasNpmRcConfig")
expect(content).not.toContain("NpmRc")
})
test("should use Bun's default registry resolution", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that it uses Bun's default resolution
expect(content).toContain("Bun's default registry resolution")
expect(content).toContain("Bun will use them automatically")
expect(content).toContain("No need to pass --registry flag")
})
test("should have correct command structure without registry", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Extract the install function
const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m)
expect(installFunctionMatch).toBeTruthy()
if (installFunctionMatch) {
const installFunction = installFunctionMatch[0]
// Verify expected arguments are present
expect(installFunction).toContain('"add"')
expect(installFunction).toContain('"--force"')
expect(installFunction).toContain('"--exact"')
expect(installFunction).toContain('"--cwd"')
expect(installFunction).toContain("Global.Path.cache")
expect(installFunction).toContain('pkg + "@" + version')
// Verify no registry argument is added
expect(installFunction).not.toContain('"--registry"')
expect(installFunction).not.toContain('args.push("--registry')
}
})
})

View File

@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
function makeUser(id: string): MessageV2.User {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: Date.now() },
agent: "default",
model: { providerID: "openai", modelID: "gpt-4" },
} as MessageV2.User
}
function makeAssistant(
id: string,
parentID: string,
finish?: string,
): MessageV2.Assistant {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
mode: "default",
agent: "default",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: "gpt-4",
providerID: "openai",
time: { created: Date.now() },
finish,
} as MessageV2.Assistant
}
describe("shouldExitLoop", () => {
test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
})
test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => {
// Simulates client clock ahead: user message ID sorts AFTER the assistant ID
const user = makeUser("01ZZZ")
const assistant = makeAssistant("01AAA", "01ZZZ", "stop")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
})
test("unfinished assistant: finish=tool-calls → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "tool-calls")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("unfinished assistant: finish=unknown → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "unknown")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("no assistant yet → does NOT exit", () => {
const user = makeUser("01AAA")
expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false)
})
test("assistant has no finish → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", undefined)
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("parentID mismatch → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01OTHER", "end_turn")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("no user message → does NOT exit", () => {
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false)
})
})

View File

@@ -1009,396 +1009,6 @@ export type GlobalEvent = {
payload: Event
}
/**
* Custom keybind configurations
*/
export type KeybindsConfig = {
/**
* Leader key for keybind combinations
*/
leader?: string
/**
* Exit the application
*/
app_exit?: string
/**
* Open external editor
*/
editor_open?: string
/**
* List available themes
*/
theme_list?: string
/**
* Toggle sidebar
*/
sidebar_toggle?: string
/**
* Toggle session scrollbar
*/
scrollbar_toggle?: string
/**
* Toggle username visibility
*/
username_toggle?: string
/**
* View status
*/
status_view?: string
/**
* Export session to editor
*/
session_export?: string
/**
* Create a new session
*/
session_new?: string
/**
* List all sessions
*/
session_list?: string
/**
* Show session timeline
*/
session_timeline?: string
/**
* Fork session from message
*/
session_fork?: string
/**
* Rename session
*/
session_rename?: string
/**
* Delete session
*/
session_delete?: string
/**
* Delete stash entry
*/
stash_delete?: string
/**
* Open provider list from model dialog
*/
model_provider_list?: string
/**
* Toggle model favorite status
*/
model_favorite_toggle?: string
/**
* Toggle showing all models
*/
model_show_all_toggle?: string
/**
* Share current session
*/
session_share?: string
/**
* Unshare current session
*/
session_unshare?: string
/**
* Interrupt current session
*/
session_interrupt?: string
/**
* Compact the session
*/
session_compact?: string
/**
* Scroll messages up by one page
*/
messages_page_up?: string
/**
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/
messages_half_page_up?: string
/**
* Scroll messages down by half page
*/
messages_half_page_down?: string
/**
* Navigate to first message
*/
messages_first?: string
/**
* Navigate to last message
*/
messages_last?: string
/**
* Navigate to next message
*/
messages_next?: string
/**
* Navigate to previous message
*/
messages_previous?: string
/**
* Navigate to last user message
*/
messages_last_user?: string
/**
* Copy message
*/
messages_copy?: string
/**
* Undo message
*/
messages_undo?: string
/**
* Redo message
*/
messages_redo?: string
/**
* Toggle code block concealment in messages
*/
messages_toggle_conceal?: string
/**
* Toggle tool details visibility
*/
tool_details?: string
/**
* List available models
*/
model_list?: string
/**
* Next recently used model
*/
model_cycle_recent?: string
/**
* Previous recently used model
*/
model_cycle_recent_reverse?: string
/**
* Next favorite model
*/
model_cycle_favorite?: string
/**
* Previous favorite model
*/
model_cycle_favorite_reverse?: string
/**
* List available commands
*/
command_list?: string
/**
* List agents
*/
agent_list?: string
/**
* Next agent
*/
agent_cycle?: string
/**
* Previous agent
*/
agent_cycle_reverse?: string
/**
* Toggle auto-accept mode for permissions
*/
permission_auto_accept_toggle?: string
/**
* Cycle model variants
*/
variant_cycle?: string
/**
* Clear input field
*/
input_clear?: string
/**
* Paste from clipboard
*/
input_paste?: string
/**
* Submit input
*/
input_submit?: string
/**
* Insert newline in input
*/
input_newline?: string
/**
* Move cursor left in input
*/
input_move_left?: string
/**
* Move cursor right in input
*/
input_move_right?: string
/**
* Move cursor up in input
*/
input_move_up?: string
/**
* Move cursor down in input
*/
input_move_down?: string
/**
* Select left in input
*/
input_select_left?: string
/**
* Select right in input
*/
input_select_right?: string
/**
* Select up in input
*/
input_select_up?: string
/**
* Select down in input
*/
input_select_down?: string
/**
* Move to start of line in input
*/
input_line_home?: string
/**
* Move to end of line in input
*/
input_line_end?: string
/**
* Select to start of line in input
*/
input_select_line_home?: string
/**
* Select to end of line in input
*/
input_select_line_end?: string
/**
* Move to start of visual line in input
*/
input_visual_line_home?: string
/**
* Move to end of visual line in input
*/
input_visual_line_end?: string
/**
* Select to start of visual line in input
*/
input_select_visual_line_home?: string
/**
* Select to end of visual line in input
*/
input_select_visual_line_end?: string
/**
* Move to start of buffer in input
*/
input_buffer_home?: string
/**
* Move to end of buffer in input
*/
input_buffer_end?: string
/**
* Select to start of buffer in input
*/
input_select_buffer_home?: string
/**
* Select to end of buffer in input
*/
input_select_buffer_end?: string
/**
* Delete line in input
*/
input_delete_line?: string
/**
* Delete to end of line in input
*/
input_delete_to_line_end?: string
/**
* Delete to start of line in input
*/
input_delete_to_line_start?: string
/**
* Backspace in input
*/
input_backspace?: string
/**
* Delete character in input
*/
input_delete?: string
/**
* Undo in input
*/
input_undo?: string
/**
* Redo in input
*/
input_redo?: string
/**
* Move word forward in input
*/
input_word_forward?: string
/**
* Move word backward in input
*/
input_word_backward?: string
/**
* Select word forward in input
*/
input_select_word_forward?: string
/**
* Select word backward in input
*/
input_select_word_backward?: string
/**
* Delete word forward in input
*/
input_delete_word_forward?: string
/**
* Delete word backward in input
*/
input_delete_word_backward?: string
/**
* Previous history item
*/
history_previous?: string
/**
* Next history item
*/
history_next?: string
/**
* Next child session
*/
session_child_cycle?: string
/**
* Previous child session
*/
session_child_cycle_reverse?: string
/**
* Go to parent session
*/
session_parent?: string
/**
* Suspend terminal
*/
terminal_suspend?: string
/**
* Toggle terminal title
*/
terminal_title_toggle?: string
/**
* Toggle tips on home screen
*/
tips_toggle?: string
/**
* Toggle thinking blocks visibility
*/
display_thinking?: string
}
/**
* Log level
*/

View File

@@ -1,40 +0,0 @@
import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2/client"
/**
* Find assistant messages that are replies to a given user message.
*
* Scans forward from the user message index first, then falls back to scanning
* backward. The backward scan handles clock skew where assistant messages
* (generated server-side) sort before the user message (generated client-side
* with an ahead clock) in the ID-sorted array.
*/
export function findAssistantMessages(
messages: MessageType[],
userIndex: number,
userID: string,
): AssistantMessage[] {
if (userIndex < 0 || userIndex >= messages.length) return []
const result: AssistantMessage[] = []
// Scan forward from user message
for (let i = userIndex + 1; i < messages.length; i++) {
const item = messages[i]
if (!item) continue
if (item.role === "user") break
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
}
// Scan backward to find assistant messages that sort before the user
// message due to clock skew between client and server
if (result.length === 0) {
for (let i = userIndex - 1; i >= 0; i--) {
const item = messages[i]
if (!item) continue
if (item.role === "user") break
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
}
}
return result
}

View File

@@ -820,7 +820,7 @@
[data-slot="question-body"] {
display: flex;
flex-direction: column;
gap: 0;
gap: 16px;
flex: 1;
min-height: 0;
padding: 8px 8px 0;
@@ -900,7 +900,7 @@
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
padding: 16px 10px 0;
padding: 0 10px;
}
[data-slot="question-hint"] {
@@ -1055,14 +1055,6 @@
white-space: normal;
overflow-wrap: anywhere;
}
&[data-picked="true"] {
[data-slot="question-custom-input"]:focus-visible {
outline: none;
outline-offset: 0;
border-radius: 0;
}
}
}
[data-slot="question-custom"] {

View File

@@ -7,7 +7,6 @@ import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { findAssistantMessages } from "./find-assistant-messages"
import { AssistantParts, Message, Part, PART_MAPPING, type UserActions } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
@@ -274,7 +273,14 @@ export function SessionTurn(
const index = messageIndex()
if (index < 0) return emptyAssistant
return findAssistantMessages(messages, index, msg.id)
const result: AssistantMessage[] = []
for (let i = index + 1; i < messages.length; i++) {
const item = messages[i]
if (!item) continue
if (item.role === "user") break
if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
}
return result
},
emptyAssistant,
{ equals: same },

View File

@@ -135,25 +135,12 @@ export function generateScale(seed: HexColor, isDark: boolean): HexColor[] {
const scale: HexColor[] = []
const lightSteps = isDark
? [
0.182,
0.21,
0.261,
0.302,
0.341,
0.387,
0.443,
0.514,
base.l,
Math.max(0, base.l - 0.017),
Math.min(0.94, Math.max(0.84, base.l + 0.02)),
0.975,
]
: [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.49, 0.27]
? [0.182, 0.21, 0.261, 0.302, 0.341, 0.387, 0.443, 0.514, base.l, Math.max(0, base.l - 0.017), 0.8, 0.93]
: [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.548, 0.33]
const chromaMultipliers = isDark
? [0.34, 0.45, 0.64, 0.82, 0.96, 1.06, 1.14, 1.2, 1.24, 1.28, 1.34, 1.08]
: [0.12, 0.24, 0.46, 0.68, 0.84, 0.98, 1.08, 1.16, 1.22, 1.26, 1.18, 0.98]
? [0.205, 0.275, 0.46, 0.62, 0.71, 0.79, 0.87, 0.97, 1.04, 1.03, 1, 0.58]
: [0.045, 0.128, 0.34, 0.5, 0.61, 0.69, 0.77, 0.89, 1, 1, 0.97, 0.56]
for (let i = 0; i < 12; i++) {
scale.push(
@@ -168,35 +155,10 @@ export function generateScale(seed: HexColor, isDark: boolean): HexColor[] {
return scale
}
export function generateNeutralScale(seed: HexColor, isDark: boolean, ink?: HexColor): HexColor[] {
if (ink) {
const base = hexToOklch(seed)
const lift = (tone: number) =>
oklchToHex({
l: base.l + (1 - base.l) * tone,
c: base.c * Math.max(0, 1 - tone),
h: base.h,
})
const sink = (tone: number) =>
oklchToHex({
l: base.l * (1 - tone),
c: base.c * Math.max(0, 1 - tone * 0.3),
h: base.h,
})
const bg = isDark
? sink(clamp(0.06 + Math.max(0, base.l - 0.18) * 0.22 + base.c * 1.4, 0.06, 0.14))
: base.l < 0.82
? lift(0.86)
: lift(clamp(0.1 + base.c * 3.2 + Math.max(0, 0.95 - base.l) * 0.35, 0.1, 0.28))
const steps = isDark
? [0, 0.03, 0.055, 0.085, 0.125, 0.18, 0.255, 0.35, 0.5, 0.67, 0.84, 0.975]
: [0, 0.022, 0.042, 0.068, 0.102, 0.146, 0.208, 0.296, 0.432, 0.61, 0.81, 0.965]
return steps.map((step) => mixColors(bg, ink, step))
}
export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] {
const base = hexToOklch(seed)
const scale: HexColor[] = []
const neutralChroma = Math.min(base.c, isDark ? 0.05 : 0.04)
const neutralChroma = Math.min(base.c, 0.02)
const lightSteps = isDark
? [0.2, 0.226, 0.256, 0.277, 0.301, 0.325, 0.364, 0.431, base.l, 0.593, 0.706, 0.946]

View File

@@ -77,7 +77,7 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
init: (props: { defaultTheme?: string }) => {
const [store, setStore] = createStore({
themes: DEFAULT_THEMES as Record<string, DesktopTheme>,
themeId: normalize(props.defaultTheme) ?? "oc-2",
@@ -119,15 +119,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
})
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
applyThemeCss(theme, themeId, mode)
props.onThemeApplied?.(theme, mode)
}
createEffect(() => {
const theme = store.themes[store.themeId]
if (theme) {
applyTheme(theme, store.themeId, store.mode)
applyThemeCss(theme, store.themeId, store.mode)
}
})
@@ -176,7 +171,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
? getSystemMode()
: store.previewScheme
: store.mode
applyTheme(theme, next, previewMode)
applyThemeCss(theme, next, previewMode)
},
previewColorScheme: (scheme: ColorScheme) => {
setStore("previewScheme", scheme)
@@ -184,7 +179,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const id = store.previewThemeId ?? store.themeId
const theme = store.themes[id]
if (theme) {
applyTheme(theme, id, previewMode)
applyThemeCss(theme, id, previewMode)
}
},
commitPreview: () => {
@@ -202,7 +197,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("previewScheme", null)
const theme = store.themes[store.themeId]
if (theme) {
applyTheme(theme, store.themeId, store.mode)
applyThemeCss(theme, store.themeId, store.mode)
}
},
}

View File

@@ -1,11 +1,11 @@
import type { ColorValue, DesktopTheme, HexColor, ResolvedTheme, ThemeVariant } from "./types"
import { blend, generateNeutralScale, generateScale, hexToOklch, hexToRgb, shift, withAlpha } from "./color"
import { blend, generateNeutralScale, generateScale, hexToOklch, oklchToHex, shift, withAlpha } from "./color"
export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): ResolvedTheme {
const colors = getColors(variant)
const { overrides = {} } = variant
const neutral = generateNeutralScale(colors.neutral, isDark, colors.ink)
const neutral = generateNeutralScale(colors.neutral, isDark)
const primary = generateScale(colors.primary, isDark)
const accent = generateScale(colors.accent, isDark)
const success = generateScale(colors.success, isDark)
@@ -39,20 +39,12 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
isDark,
)
const ink = colors.ink ?? colors.neutral
const tint = hasInk ? hexToOklch(ink) : undefined
const body = tint
? shift(ink, {
l: isDark ? Math.max(0, 0.88 - tint.l) * 0.4 : -Math.max(0, tint.l - 0.18) * 0.24,
c: isDark ? 1.04 : 1.02,
})
: undefined
const backgroundOverride = overrides["background-base"]
const backgroundHex = getHex(backgroundOverride)
const overlay = noInk || (Boolean(backgroundOverride) && !backgroundHex)
const content = (seed: HexColor, scale: HexColor[]) => {
const base = hexToOklch(seed)
const value = isDark ? (base.l > 0.84 ? shift(seed, { c: 1.18 }) : scale[10]) : scale[10]
return shift(value, { l: isDark ? 0.034 : -0.024, c: isDark ? 1.3 : 1.18 })
const value = isDark ? seed : hexToOklch(seed).l > 0.82 ? scale[10] : seed
return shift(value, { c: isDark ? 1.16 : 1.1 })
}
const modified = () => {
if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00"
@@ -95,9 +87,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
const compactInkBackground =
colors.compact && hasInk && isDark
? {
base: neutral[0],
weak: neutral[1],
strong: neutral[0],
base: neutral[2],
weak: neutral[3],
strong: neutral[1],
stronger: neutral[2],
}
: undefined
@@ -126,40 +118,6 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
)
const neutralAlpha = noInk ? generateNeutralOverlayScale(neutral, isDark) : generateNeutralAlphaScale(neutral, isDark)
const brandb = brandl[isDark ? 9 : 8]
const brandh = brandl[isDark ? 10 : 9]
const interb = interactive[isDark ? 5 : 4]
const interh = interactive[isDark ? 6 : 5]
const interw = interactive[isDark ? 4 : 3]
const succb = success[isDark ? 5 : 4]
const succw = success[isDark ? 4 : 3]
const succs = success[10]
const warnb = (noInk && isDark ? warningl : warning)[isDark ? 5 : 4]
const warnw = (noInk && isDark ? warningl : warning)[isDark ? 4 : 3]
const warns = (noInk && isDark ? warningl : warning)[10]
const critb = error[isDark ? 5 : 4]
const critw = error[isDark ? 4 : 3]
const crits = error[10]
const infob = (noInk && isDark ? infol : info)[isDark ? 5 : 4]
const infow = (noInk && isDark ? infol : info)[isDark ? 4 : 3]
const infos = (noInk && isDark ? infol : info)[10]
const lum = (hex: HexColor) => {
const rgb = hexToRgb(hex)
const lift = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4))
return 0.2126 * lift(rgb.r) + 0.7152 * lift(rgb.g) + 0.0722 * lift(rgb.b)
}
const hit = (a: HexColor, b: HexColor) => {
const x = lum(a)
const y = lum(b)
const light = Math.max(x, y)
const dark = Math.min(x, y)
return (light + 0.05) / (dark + 0.05)
}
const on = (fill: HexColor) => {
const light = "#ffffff" as HexColor
const dark = "#000000" as HexColor
return hit(light, fill) > hit(dark, fill) ? light : dark
}
const tokens: ResolvedTheme = {}
@@ -183,8 +141,8 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
: (withAlpha(neutral[3], 0.09) as ColorValue)
tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"]
tokens["surface-raised-base"] = neutralAlpha[0]
tokens["surface-float-base"] = isDark ? (hasInk ? neutral[1] : neutral[0]) : noInk ? shadow[0] : neutral[11]
tokens["surface-float-base-hover"] = isDark ? (hasInk ? neutral[2] : neutral[1]) : noInk ? shadow[1] : neutral[10]
tokens["surface-float-base"] = isDark ? neutral[0] : noInk ? shadow[0] : neutral[11]
tokens["surface-float-base-hover"] = isDark ? neutral[1] : noInk ? shadow[1] : neutral[10]
tokens["surface-raised-base-hover"] = neutralAlpha[1]
tokens["surface-raised-base-active"] = neutralAlpha[2]
tokens["surface-raised-strong"] = isDark ? neutralAlpha[3] : neutral[0]
@@ -196,26 +154,26 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["surface-strong"] = isDark ? neutralAlpha[6] : "#ffffff"
tokens["surface-raised-stronger-non-alpha"] = isDark ? neutral[2] : "#ffffff"
tokens["surface-brand-base"] = brandb
tokens["surface-brand-hover"] = brandh
tokens["surface-brand-base"] = brandl[8]
tokens["surface-brand-hover"] = brandl[9]
tokens["surface-interactive-base"] = interb
tokens["surface-interactive-hover"] = interh
tokens["surface-interactive-weak"] = interw
tokens["surface-interactive-weak-hover"] = noInk && isDark ? interl[5] : interb
tokens["surface-interactive-base"] = interactive[isDark ? 4 : 3]
tokens["surface-interactive-hover"] = interactive[isDark ? 5 : 4]
tokens["surface-interactive-weak"] = interactive[isDark ? 3 : 2]
tokens["surface-interactive-weak-hover"] = noInk && isDark ? interl[4] : interactive[isDark ? 4 : 3]
tokens["surface-success-base"] = succb
tokens["surface-success-weak"] = succw
tokens["surface-success-strong"] = succs
tokens["surface-warning-base"] = warnb
tokens["surface-warning-weak"] = warnw
tokens["surface-warning-strong"] = warns
tokens["surface-critical-base"] = critb
tokens["surface-critical-weak"] = critw
tokens["surface-critical-strong"] = crits
tokens["surface-info-base"] = infob
tokens["surface-info-weak"] = infow
tokens["surface-info-strong"] = infos
tokens["surface-success-base"] = success[isDark ? 4 : 3]
tokens["surface-success-weak"] = success[isDark ? 3 : 2]
tokens["surface-success-strong"] = success[9]
tokens["surface-warning-base"] = (noInk && isDark ? warningl : warning)[isDark ? 4 : 3]
tokens["surface-warning-weak"] = (noInk && isDark ? warningl : warning)[isDark ? 3 : 2]
tokens["surface-warning-strong"] = (noInk && isDark ? warningl : warning)[9]
tokens["surface-critical-base"] = error[isDark ? 4 : 3]
tokens["surface-critical-weak"] = error[isDark ? 3 : 2]
tokens["surface-critical-strong"] = error[9]
tokens["surface-info-base"] = (noInk && isDark ? infol : info)[isDark ? 4 : 3]
tokens["surface-info-weak"] = (noInk && isDark ? infol : info)[isDark ? 3 : 2]
tokens["surface-info-strong"] = (noInk && isDark ? infol : info)[9]
tokens["surface-diff-unchanged-base"] = isDark ? neutral[0] : "#ffffff00"
tokens["surface-diff-skip-base"] = isDark ? neutralAlpha[0] : neutral[1]
@@ -242,16 +200,16 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["input-focus"] = interactive[0]
tokens["input-disabled"] = neutral[3]
tokens["text-base"] = hasInk ? (body as HexColor) : noInk ? (isDark ? neutralAlpha[10] : neutral[10]) : neutral[10]
tokens["text-base"] = hasInk ? ink : noInk ? (isDark ? neutralAlpha[10] : neutral[10]) : neutral[10]
tokens["text-weak"] = hasInk
? shift(body as HexColor, { l: isDark ? -0.11 : 0.11, c: 0.9 })
? shift(ink, { l: isDark ? -0.18 : 0.16, c: 0.88 })
: noInk
? isDark
? neutralAlpha[8]
: neutral[8]
: neutral[8]
tokens["text-weaker"] = hasInk
? shift(body as HexColor, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 })
? shift(ink, { l: isDark ? -0.3 : 0.26, c: isDark ? 0.74 : 0.68 })
: noInk
? isDark
? neutralAlpha[7]
@@ -259,8 +217,8 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
: neutral[7]
tokens["text-strong"] = hasInk
? isDark && colors.compact
? blend("#ffffff", body as HexColor, 0.9)
: shift(body as HexColor, { l: isDark ? 0.055 : -0.07, c: 1.04 })
? blend("#ffffff", ink, 0.82)
: shift(ink, { l: isDark ? 0.06 : -0.09, c: 1 })
: noInk
? isDark
? neutralAlpha[11]
@@ -276,29 +234,29 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["text-invert-weak"] = isDark ? neutral[8] : neutral[2]
tokens["text-invert-weaker"] = isDark ? neutral[7] : neutral[3]
tokens["text-invert-strong"] = isDark ? neutral[11] : neutral[0]
tokens["text-interactive-base"] = interactive[isDark ? 10 : 9]
tokens["text-on-brand-base"] = on(brandb)
tokens["text-on-interactive-base"] = on(interb)
tokens["text-on-interactive-weak"] = on(interb)
tokens["text-on-success-base"] = on(succb)
tokens["text-on-critical-base"] = on(critb)
tokens["text-on-critical-weak"] = on(critb)
tokens["text-on-critical-strong"] = on(crits)
tokens["text-on-warning-base"] = on(warnb)
tokens["text-on-info-base"] = on(infob)
tokens["text-interactive-base"] = interactive[isDark ? 10 : 8]
tokens["text-on-brand-base"] = neutralAlpha[10]
tokens["text-on-interactive-base"] = isDark ? neutral[11] : neutral[0]
tokens["text-on-interactive-weak"] = neutralAlpha[10]
tokens["text-on-success-base"] = success[isDark ? 8 : 9]
tokens["text-on-critical-base"] = error[isDark ? 8 : 9]
tokens["text-on-critical-weak"] = error[7]
tokens["text-on-critical-strong"] = error[11]
tokens["text-on-warning-base"] = neutralAlpha[10]
tokens["text-on-info-base"] = neutralAlpha[10]
tokens["text-diff-add-base"] = diffAdd[10]
tokens["text-diff-delete-base"] = diffDelete[isDark ? 8 : 9]
tokens["text-diff-delete-strong"] = diffDelete[11]
tokens["text-diff-add-strong"] = diffAdd[isDark ? 7 : 11]
tokens["text-on-info-weak"] = on(infob)
tokens["text-on-info-strong"] = on(infos)
tokens["text-on-warning-weak"] = on(warnb)
tokens["text-on-warning-strong"] = on(warns)
tokens["text-on-success-weak"] = on(succb)
tokens["text-on-success-strong"] = on(succs)
tokens["text-on-brand-weak"] = on(brandb)
tokens["text-on-brand-weaker"] = on(brandb)
tokens["text-on-brand-strong"] = on(brandh)
tokens["text-on-info-weak"] = neutralAlpha[8]
tokens["text-on-info-strong"] = neutralAlpha[11]
tokens["text-on-warning-weak"] = neutralAlpha[8]
tokens["text-on-warning-strong"] = neutralAlpha[11]
tokens["text-on-success-weak"] = success[isDark ? 7 : 5]
tokens["text-on-success-strong"] = success[11]
tokens["text-on-brand-weak"] = neutralAlpha[8]
tokens["text-on-brand-weaker"] = neutralAlpha[7]
tokens["text-on-brand-strong"] = neutralAlpha[11]
tokens["button-primary-base"] = neutral[11]
tokens["button-secondary-base"] = noInk ? (isDark ? neutral[1] : neutral[0]) : isDark ? neutral[2] : neutral[0]
@@ -309,27 +267,27 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
if (noInk) {
const tone = (alpha: number) => alphaTone((isDark ? "#ffffff" : "#000000") as HexColor, alpha)
if (isDark) {
tokens["surface-base"] = tone(0.045)
tokens["surface-base-hover"] = tone(0.065)
tokens["surface-base-active"] = tone(0.095)
tokens["surface-raised-base"] = tone(0.085)
tokens["surface-raised-base-hover"] = tone(0.115)
tokens["surface-raised-base-active"] = tone(0.15)
tokens["surface-raised-strong"] = tone(0.115)
tokens["surface-raised-strong-hover"] = tone(0.17)
tokens["surface-raised-stronger"] = tone(0.17)
tokens["surface-raised-stronger-hover"] = tone(0.22)
tokens["surface-weak"] = tone(0.115)
tokens["surface-weaker"] = tone(0.15)
tokens["surface-strong"] = tone(0.22)
tokens["surface-base"] = tone(0.031)
tokens["surface-base-hover"] = tone(0.039)
tokens["surface-base-active"] = tone(0.059)
tokens["surface-raised-base"] = tone(0.059)
tokens["surface-raised-base-hover"] = tone(0.078)
tokens["surface-raised-base-active"] = tone(0.102)
tokens["surface-raised-strong"] = tone(0.078)
tokens["surface-raised-strong-hover"] = tone(0.129)
tokens["surface-raised-stronger"] = tone(0.129)
tokens["surface-raised-stronger-hover"] = tone(0.169)
tokens["surface-weak"] = tone(0.078)
tokens["surface-weaker"] = tone(0.102)
tokens["surface-strong"] = tone(0.169)
tokens["surface-raised-stronger-non-alpha"] = neutral[1]
tokens["surface-inset-base"] = withAlpha("#000000", 0.5) as ColorValue
tokens["surface-inset-base-hover"] = tokens["surface-inset-base"]
tokens["surface-inset-strong"] = withAlpha("#000000", 0.8) as ColorValue
tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"]
tokens["button-secondary-hover"] = tone(0.065)
tokens["button-ghost-hover"] = tone(0.045)
tokens["button-ghost-hover2"] = tone(0.095)
tokens["button-secondary-hover"] = tone(0.039)
tokens["button-ghost-hover"] = tone(0.031)
tokens["button-ghost-hover2"] = tone(0.059)
tokens["input-base"] = neutral[1]
tokens["input-hover"] = neutral[1]
tokens["input-selected"] = interactive[1]
@@ -337,30 +295,30 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
}
if (!isDark) {
tokens["surface-base"] = tone(0.045)
tokens["surface-base-hover"] = tone(0.08)
tokens["surface-base-active"] = tone(0.105)
tokens["surface-raised-base"] = tone(0.05)
tokens["surface-raised-base-hover"] = tone(0.08)
tokens["surface-raised-base-active"] = tone(0.125)
tokens["surface-base"] = tone(0.031)
tokens["surface-base-hover"] = tone(0.059)
tokens["surface-base-active"] = tone(0.051)
tokens["surface-raised-base"] = tone(0.031)
tokens["surface-raised-base-hover"] = tone(0.051)
tokens["surface-raised-base-active"] = tone(0.09)
tokens["surface-raised-strong"] = neutral[0]
tokens["surface-raised-strong-hover"] = "#ffffff"
tokens["surface-raised-stronger"] = "#ffffff"
tokens["surface-raised-stronger-hover"] = "#ffffff"
tokens["surface-weak"] = tone(0.08)
tokens["surface-weaker"] = tone(0.105)
tokens["surface-weak"] = tone(0.051)
tokens["surface-weaker"] = tone(0.071)
tokens["surface-strong"] = "#ffffff"
tokens["surface-raised-stronger-non-alpha"] = "#ffffff"
tokens["surface-inset-strong"] = tone(0.09)
tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"]
tokens["button-secondary-hover"] = blend("#ffffff", background, 0.04)
tokens["button-ghost-hover"] = tone(0.045)
tokens["button-ghost-hover2"] = tone(0.08)
tokens["button-ghost-hover"] = tone(0.031)
tokens["button-ghost-hover2"] = tone(0.051)
tokens["input-base"] = neutral[0]
tokens["input-hover"] = neutral[1]
}
tokens["surface-base-interactive-active"] = withAlpha(colors.interactive, isDark ? 0.18 : 0.12) as ColorValue
tokens["surface-base-interactive-active"] = withAlpha(colors.interactive, isDark ? 0.125 : 0.09) as ColorValue
}
tokens["border-base"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[6]
@@ -412,25 +370,25 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["border-focus"] = tokens["border-active"]
}
tokens["border-interactive-base"] = (noInk && isDark ? interl : interactive)[7]
tokens["border-interactive-hover"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-interactive-active"] = (noInk && isDark ? interl : interactive)[9]
tokens["border-interactive-selected"] = (noInk && isDark ? interl : interactive)[9]
tokens["border-interactive-base"] = (noInk && isDark ? interl : interactive)[6]
tokens["border-interactive-hover"] = (noInk && isDark ? interl : interactive)[7]
tokens["border-interactive-active"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-interactive-selected"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-interactive-disabled"] = neutral[7]
tokens["border-interactive-focus"] = (noInk && isDark ? interl : interactive)[9]
tokens["border-interactive-focus"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-success-base"] = (noInk && isDark ? successl : success)[6]
tokens["border-success-hover"] = (noInk && isDark ? successl : success)[7]
tokens["border-success-selected"] = (noInk && isDark ? successl : success)[9]
tokens["border-warning-base"] = (noInk && isDark ? warningl : warning)[6]
tokens["border-warning-hover"] = (noInk && isDark ? warningl : warning)[7]
tokens["border-warning-selected"] = (noInk && isDark ? warningl : warning)[9]
tokens["border-critical-base"] = error[isDark ? 5 : 6]
tokens["border-critical-hover"] = error[7]
tokens["border-critical-selected"] = error[9]
tokens["border-info-base"] = (noInk && isDark ? infol : info)[6]
tokens["border-info-hover"] = (noInk && isDark ? infol : info)[7]
tokens["border-info-selected"] = (noInk && isDark ? infol : info)[9]
tokens["border-success-base"] = (noInk && isDark ? successl : success)[5]
tokens["border-success-hover"] = (noInk && isDark ? successl : success)[6]
tokens["border-success-selected"] = (noInk && isDark ? successl : success)[8]
tokens["border-warning-base"] = (noInk && isDark ? warningl : warning)[5]
tokens["border-warning-hover"] = (noInk && isDark ? warningl : warning)[6]
tokens["border-warning-selected"] = (noInk && isDark ? warningl : warning)[8]
tokens["border-critical-base"] = error[isDark ? 4 : 5]
tokens["border-critical-hover"] = error[6]
tokens["border-critical-selected"] = error[8]
tokens["border-info-base"] = (noInk && isDark ? infol : info)[5]
tokens["border-info-hover"] = (noInk && isDark ? infol : info)[6]
tokens["border-info-selected"] = (noInk && isDark ? infol : info)[8]
tokens["border-color"] = "#ffffff"
tokens["icon-base"] = hasInk && !isDark ? tokens["text-weak"] : neutral[isDark ? 9 : 8]
@@ -453,7 +411,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["icon-strong-disabled"] = noInk && isDark ? neutral[6] : neutral[7]
tokens["icon-strong-focus"] = isDark ? "#fdfcfc" : "#020202"
tokens["icon-brand-base"] = isDark ? "#ffffff" : neutral[11]
tokens["icon-interactive-base"] = interactive[9]
tokens["icon-interactive-base"] = interactive[8]
tokens["icon-success-base"] = success[isDark ? 8 : 6]
tokens["icon-success-hover"] = success[isDark ? 9 : 7]
tokens["icon-success-active"] = success[10]
@@ -466,28 +424,28 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["icon-info-base"] = info[isDark ? 6 : 6]
tokens["icon-info-hover"] = info[7]
tokens["icon-info-active"] = info[10]
tokens["icon-on-brand-base"] = on(brandb)
tokens["icon-on-brand-hover"] = on(brandh)
tokens["icon-on-brand-selected"] = on(brandh)
tokens["icon-on-interactive-base"] = on(interb)
tokens["icon-on-brand-base"] = neutralAlpha[10]
tokens["icon-on-brand-hover"] = neutralAlpha[11]
tokens["icon-on-brand-selected"] = neutralAlpha[11]
tokens["icon-on-interactive-base"] = isDark ? neutral[11] : neutral[0]
tokens["icon-agent-plan-base"] = info[8]
tokens["icon-agent-docs-base"] = amber[8]
tokens["icon-agent-ask-base"] = blue[8]
tokens["icon-agent-build-base"] = interactive[isDark ? 10 : 8]
tokens["icon-on-success-base"] = on(succb)
tokens["icon-on-success-hover"] = on(succs)
tokens["icon-on-success-selected"] = on(succs)
tokens["icon-on-warning-base"] = on(warnb)
tokens["icon-on-warning-hover"] = on(warns)
tokens["icon-on-warning-selected"] = on(warns)
tokens["icon-on-critical-base"] = on(critb)
tokens["icon-on-critical-hover"] = on(crits)
tokens["icon-on-critical-selected"] = on(crits)
tokens["icon-on-info-base"] = on(infob)
tokens["icon-on-info-hover"] = on(infos)
tokens["icon-on-info-selected"] = on(infos)
tokens["icon-on-success-base"] = withAlpha(success[8], 0.9) as ColorValue
tokens["icon-on-success-hover"] = withAlpha(success[9], 0.9) as ColorValue
tokens["icon-on-success-selected"] = withAlpha(success[10], 0.9) as ColorValue
tokens["icon-on-warning-base"] = withAlpha(amber[8], 0.9) as ColorValue
tokens["icon-on-warning-hover"] = withAlpha(amber[9], 0.9) as ColorValue
tokens["icon-on-warning-selected"] = withAlpha(amber[10], 0.9) as ColorValue
tokens["icon-on-critical-base"] = withAlpha(error[8], 0.9) as ColorValue
tokens["icon-on-critical-hover"] = withAlpha(error[9], 0.9) as ColorValue
tokens["icon-on-critical-selected"] = withAlpha(error[10], 0.9) as ColorValue
tokens["icon-on-info-base"] = info[8]
tokens["icon-on-info-hover"] = withAlpha(info[9], 0.9) as ColorValue
tokens["icon-on-info-selected"] = withAlpha(info[10], 0.9) as ColorValue
tokens["icon-diff-add-base"] = diffAdd[10]
tokens["icon-diff-add-hover"] = diffAdd[isDark ? 9 : 11]
@@ -501,7 +459,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["syntax-comment"] = "var(--text-weak)"
tokens["syntax-regexp"] = "var(--text-base)"
tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656"
tokens["syntax-keyword"] = content(colors.accent, accent)
tokens["syntax-keyword"] = "var(--text-weak)"
tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804"
tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-variable"] = "var(--text-strong)"
@@ -510,9 +468,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80"
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[10]
tokens["syntax-warning"] = amber[10]
tokens["syntax-critical"] = error[10]
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = amber[9]
tokens["syntax-critical"] = error[9]
tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8"
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
@@ -538,18 +496,18 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["syntax-comment"] = "var(--text-weak)"
tokens["syntax-regexp"] = "var(--text-base)"
tokens["syntax-string"] = content(colors.success, success)
tokens["syntax-keyword"] = content(colors.accent, accent)
tokens["syntax-primitive"] = content(colors.primary, primary)
tokens["syntax-keyword"] = "var(--text-weak)"
tokens["syntax-primitive"] = content(colors.accent, accent)
tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-variable"] = "var(--text-strong)"
tokens["syntax-property"] = content(colors.info, info)
tokens["syntax-property"] = content(colors.primary, primary)
tokens["syntax-type"] = content(colors.warning, warning)
tokens["syntax-constant"] = content(colors.accent, accent)
tokens["syntax-constant"] = content(colors.info, info)
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[10]
tokens["syntax-warning"] = amber[10]
tokens["syntax-critical"] = error[10]
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = amber[9]
tokens["syntax-critical"] = error[9]
tokens["syntax-info"] = content(colors.info, info)
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
@@ -585,9 +543,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80"
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[10]
tokens["syntax-warning"] = amber[10]
tokens["syntax-critical"] = error[10]
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = amber[9]
tokens["syntax-critical"] = error[9]
tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8"
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
@@ -719,10 +677,17 @@ function generateNeutralOverlayScale(neutralScale: HexColor[], isDark: boolean):
function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): HexColor[] {
const alphas = isDark
? [0.05, 0.085, 0.13, 0.18, 0.24, 0.31, 0.4, 0.52, 0.64, 0.76, 0.88, 0.98]
: [0.03, 0.06, 0.1, 0.145, 0.2, 0.265, 0.35, 0.47, 0.61, 0.74, 0.86, 0.97]
? [0.024, 0.048, 0.088, 0.128, 0.17, 0.215, 0.275, 0.38, 0.46, 0.54, 0.74, 0.95]
: [0.014, 0.034, 0.066, 0.098, 0.128, 0.158, 0.208, 0.282, 0.47, 0.625, 0.515, 0.88]
return alphas.map((alpha) => blend(neutralScale[11], neutralScale[0], alpha))
return neutralScale.map((hex, i) => {
const baseOklch = hexToOklch(hex)
const targetL = isDark ? 0.1 + alphas[i] * 0.8 : 1 - alphas[i] * 0.8
return oklchToHex({
...baseOklch,
l: baseOklch.l * alphas[i] + targetL * (1 - alphas[i]),
})
})
}
function getHex(value: ColorValue | undefined): HexColor | undefined {

View File

@@ -16,13 +16,7 @@
"diffDelete": "#f5b3b3"
},
"overrides": {
"syntax-comment": "#8d88a3",
"syntax-keyword": "#7b5ae0",
"syntax-string": "#2b8a57",
"syntax-primitive": "#2f78b8",
"syntax-property": "#a96a22",
"syntax-type": "#2b8a57",
"syntax-constant": "#d94f4f"
"syntax-keyword": "#7b5ae0"
}
},
"dark": {
@@ -39,13 +33,7 @@
"diffDelete": "#ff6767"
},
"overrides": {
"syntax-comment": "#6d6a7e",
"syntax-keyword": "#a277ff",
"syntax-string": "#61ffca",
"syntax-primitive": "#82e2ff",
"syntax-property": "#ffca85",
"syntax-type": "#61ffca",
"syntax-constant": "#ff6767"
"syntax-keyword": "#a277ff"
}
}
}

View File

@@ -16,13 +16,7 @@
"diffDelete": "#e6656a"
},
"overrides": {
"syntax-comment": "#6e7681",
"syntax-keyword": "#c76a1a",
"syntax-string": "#6f8f00",
"syntax-primitive": "#b87500",
"syntax-property": "#2f86b7",
"syntax-type": "#227fc0",
"syntax-constant": "#a37acc"
"syntax-keyword": "#ea9f41"
}
},
"dark": {
@@ -39,13 +33,7 @@
"diffDelete": "#f58572"
},
"overrides": {
"syntax-comment": "#5a6673",
"syntax-keyword": "#ff8f40",
"syntax-string": "#aad94c",
"syntax-primitive": "#ffb454",
"syntax-property": "#39bae6",
"syntax-type": "#59c2ff",
"syntax-constant": "#d2a6ff"
"syntax-keyword": "#ffad66"
}
}
}

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