Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e772fc6
fix: revert "feat(app): add web input focus shortcut (#12493)" (#12639)
gigamonster256 Feb 8, 2026
ecaeb9e
fix(app): respect terminal toggle keybind when terminal is focused (#…
ryanmiville Feb 8, 2026
3408c10
chore: update nix node_modules hashes
opencode-agent[bot] Feb 8, 2026
85d0ed5
wip: zen
fwang Feb 8, 2026
7631060
feat(nix): disable build time models.dev fetching (#12644)
gigamonster256 Feb 8, 2026
19b1222
feat(nix): expose overlay for downstream use (#12643)
gigamonster256 Feb 8, 2026
d1ebe07
chore: refactoring and tests (#12629)
adamdotdevin Feb 8, 2026
d5036cf
fix(desktop): add native clipboard image paste and fix text paste (#1…
invarrow Feb 8, 2026
c639200
fix(app): Toast when session is missing on prompt-submit (#12654)
DNGriffin Feb 8, 2026
bc25efd
refine(app): tighten slash autocomplete matching (#12647)
kitlangton Feb 8, 2026
d5c86b0
chore: generate
opencode-agent[bot] Feb 8, 2026
4187a5f
chore: update nix node_modules hashes
opencode-agent[bot] Feb 8, 2026
7c6b8d7
fix(ui): context stale in prompt input (#12695)
adamdotdevin Feb 8, 2026
80c1c59
wip: zen
fwang Feb 8, 2026
e2ba54e
feat(app): improve Open in button group
edoedac0 Feb 8, 2026
27c8a08
ui: default TextField copy affordance to clipboard (#12714)
kitlangton Feb 8, 2026
9a7f54f
chore: generate
opencode-agent[bot] Feb 8, 2026
de0f4ef
fix(layout): improve workspace header truncation and item interaction…
kitlangton Feb 8, 2026
43811b6
chore: cleanup
adamdotdevin Feb 7, 2026
6490fb0
fix(console): zen workspace translation cleanup
adamdotdevin Feb 8, 2026
9ac54ad
chore: cleanup
adamdotdevin Feb 8, 2026
1e39b06
fix(app): show open-in divider
edoedac0 Feb 8, 2026
60fed02
Merge branch 'dev' into feat/openin-button-ui-2
edoedac0 Feb 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions infra/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions nix/hashes.json
Original file line number Diff line number Diff line change
@@ -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="
}
}
3 changes: 2 additions & 1 deletion nix/opencode.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
doInstallCheck = true;
versionCheckKeepEnvironment = [ "HOME" ];
versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ];
versionCheckProgramArg = "--version";

passthru = {
Expand Down
11 changes: 7 additions & 4 deletions packages/app/e2e/settings/settings-keybinds.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand Down
120 changes: 81 additions & 39 deletions packages/app/script/e2e-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,58 +84,99 @@ const runnerEnv = {
PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string>

const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
})
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | 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)
5 changes: 4 additions & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -97,6 +98,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement
Expand Down Expand Up @@ -413,7 +415,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
filterKeys: ["trigger", "title", "description"],
filterKeys: ["trigger", "title"],
onSelect: handleSlashSelect,
})

Expand Down Expand Up @@ -766,6 +768,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setCursorPosition(editorRef, promptLength(prompt.current()))
},
addPart,
readClipboardImage: platform.readClipboardImage,
})

const { abort, handleSubmit } = createPromptSubmit({
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type PromptAttachmentsInput = {
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => void
readClipboardImage?: () => Promise<File | null>
}

export function createPromptAttachments(input: PromptAttachmentsInput) {
Expand Down Expand Up @@ -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 })
}
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading