diff --git a/bun.lock b/bun.lock index fd562bd237f2..8d2fe82d78f9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "opencode", @@ -190,6 +190,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", @@ -1784,6 +1785,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], diff --git a/flake.nix b/flake.nix index ea78b1a43482..40e9d337f58b 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,26 @@ }; }); + overlays = { + default = + final: _prev: + let + node_modules = final.callPackage ./nix/node_modules.nix { + inherit rev; + }; + opencode = final.callPackage ./nix/opencode.nix { + inherit node_modules; + }; + desktop = final.callPackage ./nix/desktop.nix { + inherit opencode; + }; + in + { + inherit opencode; + opencode-desktop = desktop; + }; + }; + packages = forEachSystem ( pkgs: let diff --git a/infra/console.ts b/infra/console.ts index ba1ff15bf2d1..5abffb555a82 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -135,6 +135,16 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS8"), new sst.Secret("ZEN_MODELS9"), new sst.Secret("ZEN_MODELS10"), + new sst.Secret("ZEN_MODELS11"), + new sst.Secret("ZEN_MODELS12"), + new sst.Secret("ZEN_MODELS13"), + new sst.Secret("ZEN_MODELS14"), + new sst.Secret("ZEN_MODELS15"), + new sst.Secret("ZEN_MODELS16"), + new sst.Secret("ZEN_MODELS17"), + new sst.Secret("ZEN_MODELS18"), + new sst.Secret("ZEN_MODELS19"), + new sst.Secret("ZEN_MODELS20"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") diff --git a/nix/hashes.json b/nix/hashes.json index eb1578dcde28..ddd1253ed1a5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", - "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", - "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=", - "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" + "x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=", + "aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=", + "aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=", + "x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g=" } } diff --git a/nix/opencode.nix b/nix/opencode.nix index 23d9fbe34e04..b7d6f95947c1 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -34,6 +34,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { ''; env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_DISABLE_MODELS_FETCH = true; env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_CHANNEL = "local"; @@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; doInstallCheck = true; - versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ]; versionCheckProgramArg = "--version"; passthru = { diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index eceb82b7414c..a8e7f335266a 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" import { openSettings, closeDialog, withSession } from "../actions" -import { keybindButtonSelector } from "../selectors" +import { keybindButtonSelector, terminalSelector } from "../selectors" import { modKey } from "../utils" test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { @@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) => await closeDialog(page, dialog) + const terminal = page.locator(terminalSelector) + await expect(terminal).not.toBeVisible() + await page.keyboard.press(`${modKey}+Y`) - await page.waitForTimeout(100) + await expect(terminal).toBeVisible() - const pageStable = await page.evaluate(() => document.readyState === "complete") - expect(pageStable).toBe(true) + await page.keyboard.press(`${modKey}+Y`) + await expect(terminal).not.toBeVisible() }) test("changing command palette keybind works", async ({ page, gotoSession }) => { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 10819e69ffef..ea85829e0bcc 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: true, + fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index df2107f76d9b..96b35cfb7f4d 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -55,6 +55,7 @@ const extraArgs = (() => { const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) +const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1" const serverEnv = { ...process.env, @@ -83,58 +84,99 @@ const runnerEnv = { PLAYWRIGHT_PORT: String(webPort), } satisfies Record -const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { - cwd: opencodeDir, - env: serverEnv, - stdout: "inherit", - stderr: "inherit", -}) +let seed: ReturnType | undefined +let runner: ReturnType | undefined +let server: { stop: () => Promise | void } | undefined +let inst: { Instance: { disposeAll: () => Promise | void } } | undefined +let cleaned = false +let internalError = false + +const cleanup = async () => { + if (cleaned) return + cleaned = true + + if (seed && seed.exitCode === null) seed.kill("SIGTERM") + if (runner && runner.exitCode === null) runner.kill("SIGTERM") + + const jobs = [ + inst?.Instance.disposeAll(), + server?.stop(), + keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }), + ].filter(Boolean) + await Promise.allSettled(jobs) +} -const seedExit = await seed.exited -if (seedExit !== 0) { - process.exit(seedExit) +const shutdown = (code: number, reason: string) => { + process.exitCode = code + void cleanup().finally(() => { + console.error(`e2e-local shutdown: ${reason}`) + process.exit(code) + }) } -Object.assign(process.env, serverEnv) -process.env.AGENT = "1" -process.env.OPENCODE = "1" +const reportInternalError = (reason: string, error: unknown) => { + internalError = true + console.error(`e2e-local internal error: ${reason}`) + console.error(error) +} -const log = await import("../../opencode/src/util/log") -const install = await import("../../opencode/src/installation") -await log.Log.init({ - print: true, - dev: install.Installation.isLocal(), - level: "WARN", +process.once("SIGINT", () => shutdown(130, "SIGINT")) +process.once("SIGTERM", () => shutdown(143, "SIGTERM")) +process.once("SIGHUP", () => shutdown(129, "SIGHUP")) +process.once("uncaughtException", (error) => { + reportInternalError("uncaughtException", error) +}) +process.once("unhandledRejection", (error) => { + reportInternalError("unhandledRejection", error) }) -const servermod = await import("../../opencode/src/server/server") -const inst = await import("../../opencode/src/project/instance") -const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) -console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) +let code = 1 -const result = await (async () => { - try { - await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) +try { + seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", + }) - const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { + const seedExit = await seed.exited + if (seedExit !== 0) { + code = seedExit + } else { + Object.assign(process.env, serverEnv) + process.env.AGENT = "1" + process.env.OPENCODE = "1" + + const log = await import("../../opencode/src/util/log") + const install = await import("../../opencode/src/installation") + await log.Log.init({ + print: true, + dev: install.Installation.isLocal(), + level: "WARN", + }) + + const servermod = await import("../../opencode/src/server/server") + inst = await import("../../opencode/src/project/instance") + server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) + console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) + + await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) + runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { cwd: appDir, env: runnerEnv, stdout: "inherit", stderr: "inherit", }) - - return { code: await runner.exited } - } catch (error) { - return { error } - } finally { - await inst.Instance.disposeAll() - await server.stop() + code = await runner.exited } -})() - -if ("error" in result) { - console.error(result.error) - process.exit(1) +} catch (error) { + console.error(error) + code = 1 +} finally { + await cleanup() } -process.exit(result.code) +if (code === 0 && internalError) code = 1 + +process.exit(code) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0b303cf55129..da45c351ec76 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" @@ -97,6 +98,7 @@ export const PromptInput: Component = (props) => { const command = useCommand() const permission = usePermission() const language = useLanguage() + const platform = usePlatform() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement @@ -413,7 +415,7 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: slashCommands, key: (x) => x?.id, - filterKeys: ["trigger", "title", "description"], + filterKeys: ["trigger", "title"], onSelect: handleSlashSelect, }) @@ -766,6 +768,7 @@ export const PromptInput: Component = (props) => { setCursorPosition(editorRef, promptLength(prompt.current())) }, addPart, + readClipboardImage: platform.readClipboardImage, }) const { abort, handleSubmit } = createPromptSubmit({ diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 48eda3742326..b384bf7d84e4 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -14,6 +14,7 @@ type PromptAttachmentsInput = { setDraggingType: (type: "image" | "@mention" | null) => void focusEditor: () => void addPart: (part: ContentPart) => void + readClipboardImage?: () => Promise } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -76,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file) { + await addImageAttachment(file) + return + } + } + if (!plainText) return input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 5ed5eedadae3..a96bdcbad5b2 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -200,7 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } - if (!session) return + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return + } input.onSubmit?.() diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 68903a455b2d..e90df9a9486f 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -79,15 +79,16 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.usage).toBeNull() }) - test("memoizes by message and provider array identity", () => { + test("recomputes when message array is mutated in place", () => { const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const providers = [{ id: "openai", models: {} }] const one = getSessionContextMetrics(messages, providers) + messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) const two = getSessionContextMetrics(messages, providers) - const three = getSessionContextMetrics([...messages], providers) - expect(two).toBe(one) - expect(three).not.toBe(one) + expect(one.context?.message.id).toBe("a1") + expect(two.context?.message.id).toBe("a2") + expect(two.totalCost).toBe(1) }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 2b6edbd951dd..357205afb592 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -34,8 +34,6 @@ type Metrics = { context: Context | undefined } -const cache = new WeakMap>() - const tokenTotal = (msg: AssistantMessage) => { return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write } @@ -80,15 +78,5 @@ const build = (messages: Message[], providers: Provider[]): Metrics => { } export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { - const byProvider = cache.get(messages) - if (byProvider) { - const hit = byProvider.get(providers) - if (hit) return hit - } - - const value = build(messages, providers) - const next = byProvider ?? new WeakMap() - next.set(providers, value) - if (!byProvider) cache.set(messages, next) - return value + return build(messages, providers) } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7eaafc85423b..c7e2993ff44b 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -322,57 +322,60 @@ export function SessionHeader() { } >
- - - + +
+ + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
@@ -420,7 +423,14 @@ export function SessionHeader() { } >
- +