Compare commits

..

102 Commits

Author SHA1 Message Date
Kit Langton
44e96fd358 Merge branch 'dev' into effect-sync-event 2026-04-02 11:48:33 -04:00
Aiden Cline
510a1e8140 ignore: fix typecheck in dev (#20702) 2026-04-02 15:38:30 +00:00
opencode-agent[bot]
159ede2d5c chore: generate 2026-04-02 15:19:26 +00:00
Noam Bressler
291a857fb8 feat: add optional messageID to ShellInput (#20657) 2026-04-02 10:18:16 -05:00
opencode-agent[bot]
57a5236e71 chore: generate 2026-04-02 15:01:45 +00:00
Aiden Cline
23c8656080 refactor: split up models.dev and config model definitions to prevent coupling (#20605) 2026-04-02 10:00:43 -05:00
opencode-agent[bot]
ec3ae17e4d chore: update nix node_modules hashes 2026-04-02 10:23:59 +00:00
Brendan Allan
69d047ae7d cleanup event listeners with solid-primitives/event-listener (#20619) 2026-04-02 09:40:03 +00:00
Brendan Allan
327f62526a use solid-primitives/resize-observer across web code (#20613) 2026-04-02 17:24:10 +08:00
Shoubhit Dash
d540d363a7 refactor: simplify solid reactivity across app and web (#20497) 2026-04-02 17:14:05 +08:00
Frank
db93891373 zen: friendly trial ended message 2026-04-02 03:15:35 -04:00
Brendan Allan
0f488996b3 fix(node): set OPENCODE_CHANNEL during build (#20616) 2026-04-02 06:05:36 +00:00
opencode-agent[bot]
a6f524ca08 chore: update nix node_modules hashes 2026-04-02 04:47:27 +00:00
Frank
811c7e2494 cli: update usage exceeded error 2026-04-02 00:25:23 -04:00
opencode-agent[bot]
ebaa99aba2 chore: generate 2026-04-02 04:06:47 +00:00
dpuyosa
d66e6dc25f feat(opencode): Add Venice AI package as dependency (#20570) 2026-04-01 23:05:49 -05:00
Kit Langton
89c0db86b9 fix(sync): restore ALS for published events 2026-04-02 00:04:56 -04:00
Kit Langton
a68395bfef fix(sync): bind transaction inside effect 2026-04-01 23:58:06 -04:00
Kit Langton
2e6d7bb517 fix(sync): keep event application synchronous 2026-04-01 23:49:22 -04:00
Kit Langton
dc719269b6 refactor(sync): effectify sync event 2026-04-01 23:31:36 -04:00
Kit Langton
336d28f112 fix(cli): restore colored help logo (#20592) 2026-04-02 03:21:07 +00:00
Kit Langton
916afb5220 refactor(account): share token freshness helper (#20591) 2026-04-02 02:57:45 +00:00
Aaron Zhu
5daf2fa7f0 fix(session): compaction agent responds in same language as conversation (#20581)
Co-authored-by: Aaron Zhu <aaron@Aarons-MacBook-Air.local>
2026-04-01 21:44:16 -05:00
Valentin Vivaldi
733a3bd031 fix(core): prevent agent loop from stopping after tool calls with OpenAI-compatible providers (#14973)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:34:01 -05:00
Kit Langton
2e8e278441 fix(cli): use simple logo in CLI (#20585) 2026-04-02 02:27:09 +00:00
Kit Langton
0bae38c062 refactor(instruction): migrate to Effect service pattern (#20542) 2026-04-01 22:22:51 -04:00
Kit Langton
a09b086729 test(app): block real llm calls in e2e prompts (#20579) 2026-04-01 22:22:43 -04:00
Aiden Cline
df1c6c9e8d tui: add consent dialog when sharing for the first time (#20525) 2026-04-02 01:58:57 +00:00
opencode-agent[bot]
789d86f7b0 chore: generate 2026-04-02 01:56:34 +00:00
Kit Langton
e148b318b7 fix(build): replace require() with dynamic import() in cross-spawn-spawner (#20580) 2026-04-01 21:55:35 -04:00
MC
0cad775427 chore: add User-Agent headers for Cloudflare providers (#20538)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-01 20:02:17 -05:00
Kit Langton
00d6841f84 fix(account): refresh console tokens before expiry (#20558) 2026-04-02 00:25:24 +00:00
Sebastian
8a8f7b3e90 flock npm.add (#20557) 2026-04-02 00:21:26 +00:00
Kit Langton
c526caae7b fix: show model display name in message footer and transcript (#20539) 2026-04-02 00:17:38 +00:00
Kit Langton
b1c07488bd refactor(revert): yield SessionSummary.Service directly (#20541) 2026-04-01 20:10:59 -04:00
Kit Langton
92f8e03160 fix(test): use effect helper in snapshot race test (#20567) 2026-04-01 20:05:47 -04:00
Sebastian
f6fd43e574 Refactor plugin/config loading, add theme-only plugin package support (#20556) 2026-04-01 23:50:22 +00:00
opencode-agent[bot]
854484babf chore: generate 2026-04-01 23:49:44 +00:00
Kit Langton
e4ff1ea778 refactor(bash): use Effect ChildProcess for bash tool execution (#20496) 2026-04-01 19:48:47 -04:00
Kit Langton
26fb6b8788 refactor: add Effect-returning versions of MessageV2 functions (#20374) 2026-04-01 19:48:36 -04:00
opencode-agent[bot]
4214ae205d chore: generate 2026-04-01 23:48:30 +00:00
Kit Langton
d9d4f895bc fix(test): auto-acknowledge tool-result follow-ups in mock LLM server (#20528) 2026-04-01 23:47:26 +00:00
Kit Langton
48db7cf07a fix(opencode): batch snapshot revert without reordering (#20564) 2026-04-01 23:46:06 +00:00
Luke Parker
802d165572 chore(tui): clean up scroll config follow-up (#20561) 2026-04-02 09:36:49 +10:00
Luke Parker
f7f41dc3a0 fix(tui): apply scroll configuration uniformly across all scrollboxes (#14735) 2026-04-02 09:15:19 +10:00
Aiden Cline
1fcfb69bf7 feat: add new provider plugin hook for resolving models and sync models from github models endpoint (falls back to models.dev) (#20533) 2026-04-01 23:04:14 +00:00
Luke Parker
fa96cb9c6e Fix selection expansion by retaining focused input selections during global key events (#20205) 2026-04-02 08:43:40 +10:00
Sebastian
cc30bfc94b resolve subpath only packages for plugins (#20555) 2026-04-01 22:14:36 +00:00
Joscha Götzer
880c0a7477 fix: normalize filepath in FileTime to prevent Windows path mismatch (#20367)
Co-authored-by: JosXa <info@josxa.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-04-02 07:45:50 +10:00
Frank
eabf3caeb9 zen: sync 2026-04-01 17:41:04 -04:00
Dax
c9326fc199 refactor: replace BunProc with Npm module using @npmcli/arborist (#18308)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:01:37 +00:00
Frank
d7481f4593 wip: zen 2026-04-01 14:17:31 -04:00
Kit Langton
f3f728ec27 test(app): fix isolated backend follow-ups (#20513) 2026-04-01 17:43:19 +00:00
Kit Langton
c619caefdd fix(account): coalesce concurrent console token refreshes (#20503) 2026-04-01 13:16:35 -04:00
Kit Langton
c559af51ce test(app): migrate more e2e suites to isolated backend (#20505) 2026-04-01 13:15:42 -04:00
github-actions[bot]
d1e0a4640c Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20482#issuecomment-4171492178
2026-04-01 16:50:21 +00:00
opencode-agent[bot]
f9e71ec515 chore: update nix node_modules hashes 2026-04-01 16:47:33 +00:00
opencode-agent[bot]
ef538c9707 chore: generate 2026-04-01 16:14:37 +00:00
Kit Langton
2f405daa98 refactor: use Effect services instead of async facades in provider, auth, and file (#20480) 2026-04-01 16:13:13 +00:00
Kit Langton
a9c85b7c27 refactor(shell): use Effect ChildProcess for shell command execution (#20494) 2026-04-01 12:07:57 -04:00
Shoubhit Dash
897d83c589 refactor(init): tighten AGENTS guidance (#20422) 2026-04-01 21:37:25 +05:30
opencode-agent[bot]
0a125e5d4d chore: generate 2026-04-01 15:59:28 +00:00
Kit Langton
38d2276592 test(e2e): isolate prompt tests with per-worker backend (#20464) 2026-04-01 15:58:11 +00:00
Dax Raad
d58004a864 fall back to first agent if last used agent is not available 2026-04-01 11:09:29 -04:00
Kit Langton
5fd833aa18 refactor: standardize InstanceState variable name to state (#20267) 2026-04-01 10:39:43 -04:00
Shoubhit Dash
44f83015cd perf(review): defer offscreen diff mounts (#20469) 2026-04-01 19:29:12 +05:30
Kit Langton
9a1c9ae15a test(app): route prompt e2e through mock llm (#20383) 2026-04-01 08:28:38 -04:00
Shoubhit Dash
a3a6cf1c07 feat(comments): support file mentions (#20447) 2026-04-01 16:11:57 +05:30
Shoubhit Dash
47a676111a fix(session): add keyboard support to question dock (#20439) 2026-04-01 15:47:15 +05:30
Brendan Allan
1df5ad470a app: try to hide autofill popups in prompt input (#20197) 2026-04-01 08:43:03 +00:00
Brendan Allan
506dd75818 electron: port mergeShellEnv logic from tauri (#20192) 2026-04-01 07:01:44 +00:00
Kit Langton
c8ecd64022 test(app): add mock llm e2e fixture (#20375) 2026-03-31 21:24:39 -04:00
opencode-agent[bot]
ca376a4cff chore: update nix node_modules hashes 2026-04-01 01:15:51 +00:00
Kit Langton
7532d99e5b test: finish HTTP mock processor coverage (#20372) 2026-04-01 00:45:42 +00:00
Kit Langton
181b5f6236 refactor(prompt): use Provider service in effect layers (#20167) 2026-04-01 00:44:15 +00:00
opencode
6314f09c14 release: v1.3.13 2026-04-01 00:44:06 +00:00
Sebastian
4b4b7832aa upgrade opentui to 0.1.95 (#20369) 2026-04-01 01:53:05 +02:00
opencode-agent[bot]
4280307013 chore: update nix node_modules hashes 2026-03-31 23:19:18 +00:00
opencode-agent[bot]
9b09a7e766 chore: generate 2026-03-31 23:15:56 +00:00
Kit Langton
3fc0367b93 refactor(session): effectify SessionRevert service (#20143) 2026-03-31 19:14:49 -04:00
Kit Langton
954a6ca88e refactor(session): effectify SessionSummary service (#20142) 2026-03-31 19:14:45 -04:00
Kit Langton
0c03a3ee10 test: migrate prompt tests to HTTP mock LLM server (#20304) 2026-03-31 19:14:32 -04:00
github-actions[bot]
53330a518f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20333#issuecomment-4166038038
2026-03-31 22:35:10 +00:00
opencode
892bdebaac release: v1.3.12 2026-03-31 22:35:01 +00:00
Sebastian
18121300f3 upgrade opentui to 0.1.94 (#20357) 2026-03-31 23:54:13 +02:00
github-actions[bot]
d6d4446f46 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20342#issuecomment-4165277636
2026-03-31 20:24:07 +00:00
Major Hayden
26cc924ea2 feat: enable prompt caching and cache token tracking for google-vertex-anthropic (#20266)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-03-31 15:16:14 -05:00
Aiden Cline
4dd866d5c4 fix: rm exclusion of ai-sdk/azure in transform.ts, when we migrated to v6 the ai sdk changed the key for ai-sdk/azure so the exclusion is no longer needed (#20326) 2026-03-31 14:57:15 -05:00
opencode
beab4cc2c2 release: v1.3.11 2026-03-31 19:55:41 +00:00
Dax
567a91191a refactor(session): simplify LLM stream by replacing queue with fromAsyncIterable (#20324) 2026-03-31 15:27:51 -04:00
Aiden Cline
434d82bbe2 test: update model test fixture (#20182) 2026-03-31 16:20:01 +00:00
Aiden Cline
2929774acb chore: rm harcoded model definition from codex plugin (#20294) 2026-03-31 11:13:11 -05:00
Adam
6e61a46a84 chore: skip 2 tests 2026-03-31 10:56:06 -05:00
Yuxin Dong
2daf4b805a feat: add a dedicated system prompt for Kimi models (#20259)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
2026-03-31 17:44:17 +02:00
opencode-agent[bot]
7342e650c0 chore: update nix node_modules hashes 2026-03-31 15:33:12 +00:00
Adam
8c2e2ecc95 chore: e2e model 2026-03-31 10:14:26 -05:00
Sebastian
25a2b739e6 warn only and ignore plugins without entrypoints, default config via exports (#20284) 2026-03-31 17:14:03 +02:00
Adam
85c16926c4 chore: use paid zen model in e2e 2026-03-31 10:06:44 -05:00
Sebastian
2e78fdec43 ensure pinned plugin versions and do not run package scripts on install (#20248) 2026-03-31 16:59:43 +02:00
Sebastian
1fcb920eb4 upgrade opentui to 0.1.93 (#19950) 2026-03-31 16:50:23 +02:00
opencode
b1e89c344b release: v1.3.10 2026-03-31 13:31:37 +00:00
Dax
befbedacdc fix(session): subagents not being clickable (#20263) 2026-03-31 08:58:46 -04:00
231 changed files with 73183 additions and 42601 deletions

5
.github/VOUCHED.td vendored
View File

@@ -11,6 +11,7 @@ adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
@@ -21,8 +22,10 @@ jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-robinmordasiewicz
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-OpenCodeEngineer bot that spams issues
-toastythebot

1168
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5w+DwEvUrCly9LHZuTa1yTSD45X56cGJG8sds/N29mU=",
"aarch64-linux": "sha256-pLhyzajYinBlFyGWwPypyC8gHEU8S7fVXIs6aqgBmhg=",
"aarch64-darwin": "sha256-vN0sXYs7pLtpq7U9SorR2z6st/wMfHA3dybOnwIh1pU=",
"x86_64-darwin": "sha256-P8fgyBcZJmY5VbNxNer/EL4r/F28dNxaqheaqNZH488="
"x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
"aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
"aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
"x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
}
}

View File

@@ -25,7 +25,7 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.42",
"@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -45,7 +45,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.42",
"effect": "4.0.0-beta.43",
"ai": "6.0.138",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -1,5 +1,5 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test"
import { expect, type Locator, type Page, type Route } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -43,6 +43,27 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
const url = "**/session/*/prompt_async"
const route = async (input: Route) => {
const body = input.request().postDataJSON()
await input.continue({
postData: JSON.stringify({ ...body, noReply: true }),
headers: {
...input.request().headers(),
"content-type": "application/json",
},
})
}
await page.route(url, route)
try {
return await fn()
} finally {
await page.unroute(url, route)
}
}
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
@@ -312,10 +333,11 @@ export async function openSettings(page: Page) {
return dialog
}
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const defaultKey = "opencode.settings.dat:defaultServerUrl"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
@@ -331,6 +353,7 @@ export async function seedProjects(page: Page, input: { directory: string; extra
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
@@ -356,17 +379,18 @@ export async function seedProjects(page: Page, input: { directory: string; extra
localStorage.setItem(
key,
JSON.stringify({
list,
list: nextList,
projects: nextProjects,
lastProject,
}),
)
localStorage.setItem(defaultKey, args.serverUrl)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject() {
export async function createTestProject(input?: { serverUrl?: string }) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
@@ -381,7 +405,7 @@ export async function createTestProject() {
stdio: "ignore",
})
return resolveDirectory(root)
return resolveDirectory(root, input?.serverUrl)
}
export async function cleanupTestProject(directory: string) {
@@ -430,22 +454,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next
}
export async function resolveSlug(slug: string) {
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory)
const resolved = await resolveDirectory(directory, input?.serverUrl)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory)
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
const target = await resolveDirectory(directory, input?.serverUrl)
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
return resolveSlug(slug, input)
.then((item) => item.directory)
.catch(() => "")
},
@@ -455,15 +479,15 @@ export async function waitDir(page: Page, directory: string) {
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
const target = await resolveDirectory(input.directory, input.serverUrl)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false
@@ -473,7 +497,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && state?.sessionID) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false
}
@@ -489,9 +513,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
const sdk = createSdk(directory, serverUrl)
const target = await resolveDirectory(directory, serverUrl)
await expect
.poll(
@@ -501,7 +525,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
},
{ timeout },
)
@@ -666,8 +690,9 @@ export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
serverUrl?: string
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
@@ -1019,3 +1044,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
await expect(menu).toBeVisible()
return menu
}
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
}

136
packages/app/e2e/backend.ts Normal file
View File

@@ -0,0 +1,136 @@
import { spawn } from "node:child_process"
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
type Handle = {
url: string
stop: () => Promise<void>
}
function freePort() {
return new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.listen(0, () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire a free port")))
return
}
server.close((err) => {
if (err) reject(err)
else resolve(address.port)
})
})
})
}
async function waitForHealth(url: string, probe = "/global/health") {
const end = Date.now() + 120_000
let last = ""
while (Date.now() < end) {
try {
const res = await fetch(`${url}${probe}`)
if (res.ok) return
last = `status ${res.status}`
} catch (err) {
last = err instanceof Error ? err.message : String(err)
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
])
}
const LOG_CAP = 100
function cap(input: string[]) {
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
}
function tail(input: string[]) {
return input.slice(-40).join("")
}
export async function startBackend(label: string): Promise<Handle> {
const port = await freePort()
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = {
...process.env,
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
XDG_CONFIG_HOME: path.join(sandbox, "config"),
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string | undefined>
const out: string[] = []
const err: string[] = []
const proc = spawn(
"bun",
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
{
cwd: opencodeDir,
env,
stdio: ["ignore", "pipe", "pipe"],
},
)
proc.stdout?.on("data", (chunk) => {
out.push(String(chunk))
cap(out)
})
proc.stderr?.on("data", (chunk) => {
err.push(String(chunk))
cap(err)
})
const url = `http://127.0.0.1:${port}`
try {
await waitForHealth(url)
} catch (error) {
proc.kill("SIGTERM")
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
throw new Error(
[
`Failed to start isolated e2e backend for ${label}`,
error instanceof Error ? error.message : String(error),
tail(out),
tail(err),
]
.filter(Boolean)
.join("\n"),
)
}
return {
url,
async stop() {
if (proc.exitCode === null) {
proc.kill("SIGTERM")
await waitExit(proc)
}
if (proc.exitCode === null) {
proc.kill("SIGKILL")
await waitExit(proc)
}
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
},
}
}

View File

@@ -1,5 +1,9 @@
import { test as base, expect, type Page } from "@playwright/test"
import { ManagedRuntime } from "effect"
import type { E2EWindow } from "../src/testing/terminal"
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
import { startBackend } from "./backend"
import {
healthPhase,
cleanupSession,
@@ -11,31 +15,132 @@ import {
waitSlug,
waitSession,
} from "./actions"
import { openaiModel, withMockOpenAI } from "./prompt/mock"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
type LLMFixture = {
url: string
push: (...input: (Item | Reply)[]) => Promise<void>
pushMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
...input: (Item | Reply)[]
) => Promise<void>
textMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
value: string,
opts?: { usage?: Usage },
) => Promise<void>
toolMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
name: string,
input: unknown,
) => Promise<void>
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
tool: (name: string, input: unknown) => Promise<void>
toolHang: (name: string, input: unknown) => Promise<void>
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
fail: (message?: unknown) => Promise<void>
error: (status: number, body: unknown) => Promise<void>
hang: () => Promise<void>
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
calls: () => Promise<number>
wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number>
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
}
export const settingsKey = "settings.v3"
const seedModel = (() => {
const [providerID = "opencode", modelID = "big-pickle"] = (
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
).split("/")
return {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
type ProjectHandle = {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
sdk: ReturnType<typeof createSdk>
}
type ProjectOptions = {
extra?: string[]
model?: { providerID: string; modelID: string }
setup?: (directory: string) => Promise<void>
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
}
type TestFixtures = {
llm: LLMFixture
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
}
type WorkerFixtures = {
backend: {
url: string
sdk: (directory?: string) => ReturnType<typeof createSdk>
}
directory: string
slug: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
backend: [
async ({}, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`)
try {
await use({
url: handle.url,
sdk: (directory?: string) => createSdk(directory, handle.url),
})
} finally {
await handle.stop()
}
},
{ scope: "worker" },
],
llm: async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
await use({
url: svc.url,
push: (...input) => rt.runPromise(svc.push(...input)),
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
fail: (message) => rt.runPromise(svc.fail(message)),
error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
misses: () => rt.runPromise(svc.misses),
})
} finally {
await rt.dispose()
}
},
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
@@ -85,47 +190,93 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
})
await use((callback, options) => runProject(page, callback, options))
},
withBackendProject: async ({ page, backend }, use) => {
await use((callback, options) =>
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
)
},
withMockProject: async ({ page, llm, backend }, use) => {
await use((callback, options) =>
withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: () =>
runProject(page, callback, {
...options,
model: options?.model ?? openaiModel,
serverUrl: backend.url,
sdk: backend.sdk,
}),
}),
)
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
async function runProject<T>(
page: Page,
callback: (project: ProjectHandle) => Promise<T>,
options?: ProjectOptions & {
serverUrl?: string
sdk?: (directory?: string) => ReturnType<typeof createSdk>
},
) {
const url = options?.serverUrl
const root = await createTestProject(url ? { serverUrl: url } : undefined)
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await options?.setup?.(root)
await seedStorage(page, {
directory: root,
extra: options?.extra,
model: options?.model,
serverUrl: url,
})
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID, serverUrl: url })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await options?.beforeGoto?.({ directory: root, sdk })
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
}
async function seedStorage(
page: Page,
input: {
directory: string
extra?: string[]
model?: { providerID: string; modelID: string }
serverUrl?: string
},
) {
await seedProjects(page, input)
await page.addInitScript(() => {
await page.addInitScript((model: { providerID: string; modelID: string }) => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
@@ -143,12 +294,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
recent: [model],
user: [],
variant: {},
}),
)
})
}, input.model ?? seedModel)
}
export { expect }

View File

@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { clickListItem } from "../actions"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()

View File

@@ -10,6 +10,7 @@ import {
waitSession,
waitSessionSaved,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
@@ -81,8 +82,10 @@ test("switching back to a project opens the latest workspace session", async ({
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
await withNoReplyPrompt(page, async () => {
await prompt.fill("test")
await page.keyboard.press("Enter")
})
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")

View File

@@ -9,6 +9,7 @@ import {
waitSession,
waitSessionSaved,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
@@ -58,8 +59,10 @@ async function createSessionFromWorkspace(
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(text)
await page.keyboard.press("Enter")
await withNoReplyPrompt(page, async () => {
await prompt.fill(text)
await page.keyboard.press("Enter")
})
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())

View File

@@ -0,0 +1,56 @@
import { createSdk } from "../utils"
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) {
return JSON.stringify(hit.body)
}
export function titleMatch(hit: Hit) {
return bodyText(hit).includes("Generate a title for this conversation")
}
export function promptMatch(token: string) {
return (hit: Hit) => bodyText(hit).includes(token)
}
/**
* Match requests whose body contains the exact serialized tool input.
* The seed prompts embed JSON.stringify(input) in the prompt text, which
* gets escaped again inside the JSON body — so we double-escape to match.
*/
export function inputMatch(input: unknown) {
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
return (hit: Hit) => bodyText(hit).includes(escaped)
}
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
const sdk = createSdk(undefined, input.serverUrl)
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
try {
await sdk.global.config.update({
config: {
...prev,
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
enabled_providers: ["openai"],
provider: {
...prev.provider,
openai: {
...prev.provider?.openai,
options: {
...prev.provider?.openai?.options,
apiKey: "test-key",
baseURL: input.llmUrl,
},
},
},
},
})
return await input.fn()
} finally {
await sdk.global.config.update({ config: prev })
}
}

View File

@@ -1,47 +1,52 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test("prompt succeeds when sync message endpoint is unreachable", async ({
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await withBackendProject(
async (project) => {
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
},
{
model: openaiModel,
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await cleanupSession({ sdk, sessionID })
}
},
})
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {

View File

@@ -1,10 +1,13 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
@@ -13,54 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
async function reply(sdk: Sdk, sessionID: string, token: string) {
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
@@ -79,106 +43,133 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test("prompt history restores unsent draft with arrow navigation", async ({
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(firstToken), firstToken)
await llm.textMatch(promptMatch(secondToken), secondToken)
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await reply(project.sdk, sessionID, firstToken)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, secondToken)
await page.keyboard.press("ArrowUp")
await wait(page, second)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
},
{
model: openaiModel,
},
)
},
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
await shell(sdk, sessionID, first, firstToken)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, sessionID, second, secondToken)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("Escape")
await wait(page, "")
await page.keyboard.press("ArrowDown")
await wait(page, "")
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("Escape")
await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, first)
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, sessionID, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})

View File

@@ -2,7 +2,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
@@ -11,13 +10,12 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
const cmd = process.platform === "win32" ? "dir" : "command ls"
await gotoSession()
await prompt.click()

View File

@@ -22,43 +22,46 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await seed(project.sdk, session.id)
await project.gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await expect
.poll(
async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
await expect
.poll(
async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})
})

View File

@@ -1,8 +1,9 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
import { assistantText, sessionIDFromUrl } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
@@ -11,42 +12,44 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
}
page.on("pageerror", onPageError)
await gotoSession()
const token = `E2E_OK_${Date.now()}`
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
try {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const token = `E2E_OK_${Date.now()}`
.toContain(token)
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
} finally {
page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {

View File

@@ -1,7 +1,9 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock"
import { promptSelector } from "../selectors"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
test.setTimeout(120_000)
const errs: string[] = []
@@ -10,28 +12,37 @@ test("task tool child-session link does not trigger stale show errors", async ({
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
try {
await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const taskInput = {
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
subagent_type: "general",
}
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: taskInput.description,
prompt: taskInput.prompt,
})
trackSession(child.sessionID)
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
} finally {
page.off("pageerror", onError)
}
})

View File

@@ -13,6 +13,8 @@ import {
sessionComposerDockSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
import { modKey } from "../utils"
import { inputMatch } from "../prompt/mock"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -21,12 +23,13 @@ async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try {
return await fn(session)
} finally {
@@ -34,6 +37,17 @@ async function withDockSession<T>(
}
}
const defaultQuestions = [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
]
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
@@ -255,283 +269,410 @@ async function withMockPermission<T>(
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock default",
async (session) => {
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
},
{ trackSession: project.trackSession },
)
})
})
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
await withBackendProject(async ({ gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
],
})
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
llm,
withMockProject,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const questions = [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
]
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock child question parent",
async (session) => {
await project.gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
const child = await project.sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
try {
await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions,
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
},
{ trackSession: project.trackSession },
)
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
withBackendProject,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock child permission parent",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
const child = await project.sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
await expectPermissionOpen(page)
},
)
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
},
{ trackSession: project.trackSession },
)
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
const dock = await todoDock(page, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock todo",
async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
},
{ trackSession: project.trackSession },
)
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
const questions = [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
]
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions,
})
await expectQuestionBlocked(page)
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
},
{ trackSession: project.trackSession },
)
})
})

View File

@@ -8,11 +8,11 @@ import {
waitSession,
waitSessionIdle,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
@@ -231,11 +231,14 @@ async function goto(page: Page, directory: string, sessionID?: string) {
}
async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector)
const prompt = page.locator('[data-component="prompt-input"]')
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await withNoReplyPrompt(page, async () => {
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
})
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())

View File

@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
import { inputMatch } from "../prompt/mock"
const count = 14
@@ -40,7 +40,14 @@ function edit(file: string, prev: string, next: string) {
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
async function patchWithMock(
llm: Parameters<typeof test>[0]["llm"],
sdk: Parameters<typeof withSession>[0],
sessionID: string,
patchText: string,
) {
const callsBefore = await llm.calls()
await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
await sdk.session.promptAsync({
sessionID,
agent: "build",
@@ -54,6 +61,11 @@ async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patch
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
// Wait for the agent loop to actually start before checking idle.
// promptAsync is fire-and-forget — without this, waitSessionIdle can
// return immediately because the session status is still undefined.
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
await waitSessionIdle(sdk, sessionID, 120_000)
}
@@ -233,7 +245,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
@@ -242,16 +254,15 @@ test("review applies inline comment clicks without horizontal overflow", async (
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await withMockProject(async (project) => {
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
@@ -282,7 +293,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
})
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
@@ -291,16 +302,15 @@ test("review file comments submit on click without clipping actions", async ({ p
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await withMockProject(async (project) => {
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
@@ -332,8 +342,7 @@ test("review file comments submit on click without clipping actions", async ({ p
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
@@ -343,16 +352,15 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await withMockProject(async (project) => {
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
@@ -362,7 +370,7 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
@@ -379,15 +387,16 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.getByRole("heading", {
level: 3,
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
})
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
@@ -396,12 +405,12 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},

View File

@@ -49,15 +49,16 @@ async function seedConversation(input: {
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withBackendProject(async (project) => {
const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -81,15 +82,16 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withProj
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withBackendProject(async (project) => {
const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -128,16 +130,17 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
})
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withBackendProject(async (project) => {
const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const first = await seedConversation({

View File

@@ -31,144 +31,156 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0)
}
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
await withBackendProject(async (project) => {
await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect
.poll(
async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
})
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now()
const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await expect
.poll(
async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
})
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await expect
.poll(
async () => {
const data = await project.sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
})
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now()
const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await expect
.poll(
async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
await expect
.poll(
async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
})
})

View File

@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
export function createSdk(directory?: string, baseUrl = serverUrl) {
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
}
export async function resolveDirectory(directory: string) {
return createSdk(directory)
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
return createSdk(directory, baseUrl)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() {
const sdk = createSdk()
export async function getWorktree(baseUrl = serverUrl) {
const sdk = createSdk(undefined, baseUrl)
const result = await sdk.path.get()
const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
return data.worktree
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.9",
"version": "1.3.13",
"description": "",
"type": "module",
"exports": {
@@ -46,9 +46,10 @@
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",

View File

@@ -71,7 +71,7 @@ const serverEnv = {
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>

View File

@@ -1,6 +1,7 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
@@ -349,13 +350,12 @@ export function DebugBar() {
syncHeap()
start()
document.addEventListener("visibilitychange", vis)
makeEventListener(document, "visibilitychange", vis)
onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect()
})
})

View File

@@ -1344,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}

View File

@@ -1,4 +1,5 @@
import { onCleanup, onMount } from "solid-js"
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
onMount(() => {
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
makeEventListener(document, "dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop)
})
return {

View File

@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
expect(synthetic).toHaveLength(1)
})
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]

View File

@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [
{
id: Identifier.ascending("part"),
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
}),
} satisfies PromptRequestPart,
filePart,
...mentions,
]
})

View File

@@ -1,11 +1,11 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import {
children,
createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
@@ -46,12 +46,9 @@ export function ServerRow(props: ServerRowProps) {
})
onMount(() => {
check()
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check)
if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
createResizeObserver([nameRef, versionRef], check)
check()
})
const tooltipValue = () => (

View File

@@ -1,5 +1,6 @@
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
input.stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
makeEventListener(document, "keydown", handle, { capture: true })
})
}

View File

@@ -2,6 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown)
})
function register(cb: () => CommandOption[]): void

View File

@@ -1,7 +1,8 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
@@ -206,21 +207,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
clearHeartbeat()
}
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop()
abort.abort()
flush()

View File

@@ -1,6 +1,7 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
flush()
}
window.addEventListener("pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility)
makeEventListener(window, "pagehide", flush)
makeEventListener(document, "visibilitychange", handleVisibility)
onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose()
})
})

View File

@@ -12,6 +12,7 @@ import {
untrack,
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
@@ -215,18 +216,11 @@ export default function Layout(props: ParentProps) {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
makeEventListener(window, "pointerup", stop)
makeEventListener(window, "pointercancel", stop)
makeEventListener(window, "blur", stop)
makeEventListener(window, "blur", blur)
makeEventListener(document, "visibilitychange", hide)
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -1394,8 +1388,7 @@ export default function Layout(props: ParentProps) {
}
handleDeepLinks(drainPendingDeepLinks(window))
window.addEventListener(deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
makeEventListener(window, deepLinkEvent, handler as EventListener)
})
async function renameProject(project: LocalProject, next: string) {

View File

@@ -14,6 +14,7 @@ import {
onMount,
untrack,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
@@ -329,10 +330,9 @@ export default function Page() {
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
if (!untrack(() => prompt.ready())) return
prompt.ready()
if (!prompt.ready()) return
untrack(() => {
if (params.id || !prompt.ready()) return
if (params.id) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -1046,6 +1046,9 @@ export default function Page() {
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
commentMentions={{
items: file.searchFilesAndDirectories,
}}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@@ -1685,11 +1688,10 @@ export default function Page() {
)
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)

View File

@@ -13,6 +13,7 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -115,13 +116,9 @@ export function SessionComposerRegion(props: {
createEffect(() => {
const el = store.body
if (!el) return
const update = () => {
setStore("height", el.getBoundingClientRect().height)
}
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(store.body, update)
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (

View File

@@ -1,5 +1,6 @@
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
pull()
}
window.addEventListener(composerEvent, onEvent)
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
makeEventListener(window, composerEvent, onEvent)
})
const todos = createMemo((): Todo[] => {

View File

@@ -8,6 +8,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
@@ -29,16 +31,20 @@ function Option(props: {
label: string
description?: string
disabled: boolean
ref?: (el: HTMLButtonElement) => void
onFocus?: VoidFunction
onClick: VoidFunction
}) {
return (
<button
type="button"
ref={props.ref}
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onFocus={props.onFocus}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
@@ -66,16 +72,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
focus: 0,
})
let root: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = []
let replied = false
let focusFrame: number | undefined
const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const count = createMemo(() => options().length + 1)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -129,6 +140,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
root.style.setProperty("--question-prompt-max-height", `${max}px`)
}
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
const pickFocus = (tab: number = store.tab) => {
const list = questions()[tab]?.options ?? []
if (store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
)
}
const focus = (i: number) => {
const next = clamp(i)
setStore("focus", next)
if (store.editing) return
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
focusFrame = requestAnimationFrame(() => {
focusFrame = undefined
const el = next === options().length ? customRef : optsRef[next]
el?.focus()
})
}
onMount(() => {
let raf: number | undefined
const update = () => {
@@ -140,22 +174,22 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
update()
window.addEventListener("resize", update)
makeEventListener(window, "resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
createResizeObserver([dock, scroller], update)
onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf)
})
focus(pickFocus())
})
onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
cache.set(props.request.id, {
tab: store.tab,
@@ -231,6 +265,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const customToggle = () => {
if (sending()) return
setStore("focus", options().length)
if (!multi()) {
setStore("customOn", store.tab, true)
@@ -250,15 +285,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false)
focus(options().length)
}
const customOpen = () => {
if (sending()) return
setStore("focus", options().length)
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
}
const move = (step: number) => {
if (store.editing || sending()) return
focus(store.focus + step)
}
const nav = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (event.key === "Escape") {
event.preventDefault()
void reject()
return
}
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
if (mod && event.key === "Enter") {
if (event.repeat) return
event.preventDefault()
next()
return
}
const target =
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
if (store.editing) return
if (!(target instanceof HTMLElement)) return
if (event.altKey || event.ctrlKey || event.metaKey) return
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Home") {
event.preventDefault()
focus(0)
return
}
if (event.key !== "End") return
event.preventDefault()
focus(count() - 1)
}
const selectOption = (optIndex: number) => {
if (sending()) return
@@ -270,6 +358,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const opt = options()[optIndex]
if (!opt) return
if (multi()) {
setStore("editing", false)
toggle(opt.label)
return
}
@@ -279,6 +368,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const commitCustom = () => {
setStore("editing", false)
customUpdate(input())
focus(options().length)
}
const resizeInput = (el: HTMLTextAreaElement) => {
@@ -308,27 +398,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return
}
setStore("tab", store.tab + 1)
const tab = store.tab + 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
}
const back = () => {
if (sending()) return
if (store.tab <= 0) return
setStore("tab", store.tab - 1)
const tab = store.tab - 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
}
const jump = (tab: number) => {
if (sending()) return
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
}
return (
<DockPrompt
kind="question"
ref={(el) => (root = el)}
onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
@@ -351,7 +447,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
@@ -360,7 +456,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
{language.t("ui.common.back")}
</Button>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
<Button
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
@@ -380,6 +482,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
@@ -390,12 +494,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
fallback={
<button
type="button"
ref={customRef}
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
@@ -440,8 +546,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()

View File

@@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
@@ -91,9 +92,7 @@ export function SessionTodoDock(props: {
setStore("height", el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
createResizeObserver(el, update)
})
createEffect(() => {

View File

@@ -1,6 +1,7 @@
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -59,7 +60,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const [code, setCode] = createSignal<HTMLElement[]>([])
const getCode = () => {
const el = scroll
@@ -106,17 +107,9 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
const current = code()
if (next.length === current.length && next.every((el, i) => el === current[i])) return
setCode(next)
}
const restore = () => {
@@ -128,14 +121,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
sync()
if (code.length > 0) {
for (const item of code) {
if (code().length > 0) {
for (const item of code()) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return
if (code().length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
@@ -149,24 +142,24 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
if (code().length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
createEffect(() => {
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
})
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
@@ -302,6 +295,9 @@ export function FileTabContent(props: { tab: string }) {
comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
@@ -355,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
find?.focus()
}
window.addEventListener("keydown", onKeyDown, { capture: true })
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
createEffect(

View File

@@ -1,5 +1,6 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same"
const emptyTabs: string[] = []
@@ -171,14 +172,9 @@ export const createSizing = () => {
}
onMount(() => {
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
makeEventListener(window, "pointerup", stop)
makeEventListener(window, "pointercancel", stop)
makeEventListener(window, "blur", stop)
})
onCleanup(() => {

View File

@@ -1,4 +1,5 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import type {
@@ -30,6 +31,9 @@ export interface SessionReviewTabProps {
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
focusedFile?: string
onScrollRef?: (el: HTMLDivElement) => void
commentMentions?: {
items: (query: string) => string[] | Promise<string[]>
}
classes?: {
root?: string
header?: string
@@ -120,13 +124,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
if (scroll) {
scroll.removeEventListener("wheel", handleInteraction, { capture: true })
scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
scroll.removeEventListener("keydown", handleInteraction, { capture: true })
}
})
return (
@@ -135,11 +132,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty}
scrollRef={(el) => {
scroll = el
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "keydown", handleInteraction, { capture: true })
props.onScrollRef?.(el)
queueRestore()
}}
@@ -162,6 +159,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions}
lineCommentMention={props.commentMentions}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}

View File

@@ -1,5 +1,6 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,12 +51,8 @@ export function TerminalPanel() {
const port = window.visualViewport
sync()
window.addEventListener("resize", sync)
port?.addEventListener("resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
makeEventListener(window, "resize", sync)
if (port) makeEventListener(port, "resize", sync)
})
createEffect(() => {

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { bodyText, inputMatch, promptMatch } from "../../e2e/prompt/mock"
function hit(body: Record<string, unknown>) {
return { body }
}
describe("promptMatch", () => {
test("matches token in serialized body", () => {
const match = promptMatch("hello")
expect(match(hit({ messages: [{ role: "user", content: "say hello" }] }))).toBe(true)
expect(match(hit({ messages: [{ role: "user", content: "say goodbye" }] }))).toBe(false)
})
})
describe("inputMatch", () => {
test("matches exact tool input in chat completions body", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
// The seed prompt embeds JSON.stringify(input) in the user message
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
const body = { messages: [{ role: "user", content: prompt }] }
expect(match(hit(body))).toBe(true)
})
test("matches exact tool input in responses API body", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
const body = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
expect(match(hit(body))).toBe(true)
})
test("matches patchText with newlines", () => {
const patchText = "*** Begin Patch\n*** Add File: test.txt\n+line1\n*** End Patch"
const match = inputMatch({ patchText })
const prompt = `Use this JSON input: ${JSON.stringify({ patchText })}`
const body = { messages: [{ role: "user", content: prompt }] }
expect(match(hit(body))).toBe(true)
// Also works in responses API format
const respBody = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
expect(match(hit(respBody))).toBe(true)
})
test("does not match unrelated requests", () => {
const input = { questions: [{ header: "Need input" }] }
const match = inputMatch(input)
expect(match(hit({ messages: [{ role: "user", content: "hello" }] }))).toBe(false)
expect(match(hit({ model: "test", input: [] }))).toBe(false)
})
test("does not match partial input", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
// Only header, missing question
const partial = `Use this JSON input: ${JSON.stringify({ questions: [{ header: "Need input" }] })}`
const body = { messages: [{ role: "user", content: partial }] }
expect(match(hit(body))).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { fileURLToPath } from "node:url"
const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../e2e")
function hasPrompt(src: string) {
if (!src.includes("withProject(")) return false
if (src.includes("withNoReplyPrompt(")) return false
if (src.includes("session.promptAsync({") && !src.includes("noReply: true")) return true
if (!src.includes("promptSelector")) return false
return src.includes('keyboard.press("Enter")') || src.includes('prompt.press("Enter")')
}
describe("e2e llm guard", () => {
test("withProject specs do not submit prompt replies", async () => {
const bad: string[] = []
for await (const file of new Bun.Glob("**/*.spec.ts").scan({ cwd: dir, absolute: true })) {
const src = await Bun.file(file).text()
if (!hasPrompt(src)) continue
bad.push(path.relative(dir, file))
}
expect(bad).toEqual([])
})
})

View File

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

View File

@@ -363,6 +363,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",

View File

@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",

View File

@@ -368,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",

View File

@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",

View File

@@ -364,6 +364,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Access all the world's best coding models",
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",

View File

@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
"zen.api.error.trialEnded":
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",

View File

@@ -372,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
"zen.api.error.modelDisabled": "Le modèle est désactivé",
"zen.api.error.trialEnded":
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
"black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",

View File

@@ -367,6 +367,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
"zen.api.error.modelDisabled": "Il modello è disabilitato",
"zen.api.error.trialEnded":
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
"black.meta.description":

View File

@@ -369,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
"zen.api.error.modelDisabled": "モデルが無効です",
"zen.api.error.trialEnded":
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
"black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
"black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",

View File

@@ -363,6 +363,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
"zen.api.error.trialEnded":
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
"black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
"black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",

View File

@@ -368,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktivert",
"zen.api.error.trialEnded":
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
"black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",

View File

@@ -369,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model jest wyłączony",
"zen.api.error.trialEnded":
"Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
"black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",

View File

@@ -373,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель отключена",
"zen.api.error.trialEnded":
"Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
"black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",

View File

@@ -365,6 +365,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
"zen.api.error.trialEnded":
"โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
"black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",

View File

@@ -372,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model devre dışı",
"zen.api.error.trialEnded":
"{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}",
"black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
"black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",

View File

@@ -349,6 +349,7 @@ export const dict = {
"您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已禁用",
"zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
"black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",

View File

@@ -349,6 +349,7 @@ export const dict = {
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已停用",
"zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
"black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",

View File

@@ -1,541 +0,0 @@
[data-page="zen"][data-route="rankings"] {
[data-component="hero"][data-variant="rankings"] {
padding-top: var(--vertical-padding);
padding-bottom: var(--vertical-padding);
[data-slot="hero-copy"] {
p {
margin-bottom: 0;
max-width: 44rem;
}
}
}
[data-component="container"] {
border: none;
}
[data-component="footer"] {
border-top: none;
}
[data-component="rankings-section"] {
padding: var(--vertical-padding) var(--padding);
h3 {
font-size: 16px;
font-weight: 700;
color: var(--color-text-strong);
}
}
[data-slot="chart"] {
margin-top: 1.5rem;
background: var(--color-background-weak);
border: 1px solid var(--color-border-weak);
border-radius: 0.5rem;
padding: 2rem 1.5rem 1.5rem;
overflow: visible;
@media (max-width: 40rem) {
padding: 1.5rem 1rem 1rem;
}
}
[data-slot="bars"] {
display: flex;
align-items: flex-end;
gap: 0.75rem;
height: 20rem;
@media (max-width: 40rem) {
height: 14rem;
gap: 0.5rem;
}
}
[data-slot="bar-group"] {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
height: 100%;
justify-content: flex-end;
min-width: 2.5rem;
cursor: default;
transition: opacity 0.15s ease;
&[data-dimmed] {
opacity: 0.35;
}
}
[data-slot="bar-label"] {
color: var(--color-text-weak);
font-size: 0.75rem;
white-space: nowrap;
}
[data-slot="bar-wrap"] {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
}
[data-slot="bar"] {
width: 100%;
max-width: 4rem;
border-radius: 0.25rem 0.25rem 0 0;
min-height: 2px;
display: flex;
flex-direction: column-reverse;
gap: 1px;
overflow: hidden;
span {
display: block;
min-height: 1px;
}
span:last-child {
border-radius: 0.25rem 0.25rem 0 0;
}
}
[data-slot="bar-week"] {
color: var(--color-text-weak);
font-size: 0.75rem;
white-space: nowrap;
}
[data-slot="tooltip"] {
position: fixed;
z-index: 100;
transform: translateY(-100%);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
padding: 0.75rem;
min-width: 14rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
pointer-events: none;
strong {
display: block;
color: var(--color-text-strong);
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 0.125rem;
}
}
[data-slot="tooltip-total"] {
display: block;
color: var(--color-text-weak);
font-size: 0.75rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="tooltip-row"] {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--color-text);
padding: 0.175rem 0;
i {
width: 0.5rem;
height: 0.5rem;
border-radius: 2px;
flex: none;
}
span:last-child {
text-align: right;
color: var(--color-text-strong);
font-weight: 500;
}
}
[data-slot="legend"] {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-weak);
}
[data-slot="legend-item"] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--color-text);
i {
width: 0.75rem;
height: 0.75rem;
border-radius: 2px;
flex: none;
}
}
[data-slot="subtitle"] {
color: var(--color-text);
margin-top: 0.5rem;
}
[data-slot="lb-podium"] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1.5rem;
@media (max-width: 40rem) {
grid-template-columns: 1fr;
}
}
[data-slot="lb-podium-item"] {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1.25rem;
background: var(--color-background-weak);
border: 1px solid var(--color-border-weak);
border-radius: 0.5rem;
strong {
color: var(--color-text-strong);
font-size: 1.0625rem;
font-weight: 600;
}
}
[data-slot="lb-podium-rank"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
[data-slot="lb-podium-item"][data-rank="1"] [data-slot="lb-podium-rank"] {
background: hsl(45, 93%, 47%);
color: hsl(45, 100%, 10%);
}
[data-slot="lb-podium-item"][data-rank="2"] [data-slot="lb-podium-rank"] {
background: hsl(0, 0%, 75%);
color: hsl(0, 0%, 15%);
}
[data-slot="lb-podium-item"][data-rank="3"] [data-slot="lb-podium-rank"] {
background: hsl(30, 60%, 50%);
color: hsl(30, 60%, 10%);
}
[data-slot="lb-podium-tokens"] {
color: var(--color-text-weak);
font-size: 0.875rem;
}
[data-slot="leaderboard"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 2rem;
margin-top: 1rem;
@media (max-width: 58rem) {
grid-template-columns: 1fr;
}
}
[data-slot="lb-col"] {
display: grid;
align-content: start;
}
[data-slot="lb-col"] [data-slot="lb-row"]:last-child {
border-bottom: none;
}
[data-slot="lb-row"] {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="lb-row"]:last-child {
border-bottom: none;
}
[data-slot="lb-rank"] {
color: var(--color-text-weak);
font-size: 0.875rem;
}
[data-slot="lb-info"] {
min-width: 0;
strong {
display: block;
color: var(--color-text-strong);
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
[data-slot="lb-tokens"] {
white-space: nowrap;
color: var(--color-text);
font-size: 0.875rem;
}
[data-slot="lb-toggle"] {
display: block;
margin: 1rem auto 0;
padding: 0;
background: none;
border: none;
color: var(--color-text-weak);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.14em;
text-decoration-thickness: 1px;
&:hover {
color: var(--color-text-strong);
}
}
[data-slot="share-bars"] {
display: flex;
gap: 0.5rem;
height: 20rem;
@media (max-width: 40rem) {
height: 14rem;
gap: 0.375rem;
}
}
[data-slot="share-col"] {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 2.5rem;
cursor: default;
transition: opacity 0.15s ease;
&[data-dimmed] {
opacity: 0.35;
}
}
[data-slot="share-stack"] {
flex: 1;
display: flex;
flex-direction: column-reverse;
gap: 1px;
border-radius: 0.25rem;
overflow: hidden;
span {
display: block;
min-height: 1px;
}
}
[data-slot="share-week"] {
color: var(--color-text-weak);
font-size: 0.75rem;
text-align: center;
white-space: nowrap;
}
[data-slot="pricing-table"] {
margin-top: 1.5rem;
border: 1px solid var(--color-border-weak);
border-radius: 0.5rem;
overflow: hidden;
}
[data-slot="pricing-header"],
[data-slot="pricing-row"] {
display: grid;
grid-template-columns: minmax(0, 1.5fr) repeat(4, minmax(0, 1fr));
gap: 0.75rem;
padding: 0.75rem 1.25rem;
&[data-cols="4"] {
grid-template-columns: minmax(0, 1.5fr) repeat(3, minmax(0, 1fr));
}
&[data-cols="3"] {
grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(0, 1fr));
}
&[data-cols="2"] {
grid-template-columns: minmax(0, 1fr) auto;
}
@media (max-width: 40rem) {
padding: 0.625rem 0.75rem;
gap: 0.5rem;
}
}
[data-slot="pricing-header"] {
background: var(--color-background-weak);
border-bottom: 1px solid var(--color-border-weak);
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-weak);
}
[data-slot="pricing-row"] {
border-bottom: 1px solid var(--color-border-weak);
font-size: 0.875rem;
color: var(--color-text);
&:last-child {
border-bottom: none;
}
}
[data-slot="cost-list"] {
margin-top: 1.5rem;
}
[data-slot="cost-group"] {
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
}
}
[data-slot="cost-row"] {
padding: 0.5rem 0;
}
[data-slot="cost-main"] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.2fr);
align-items: center;
gap: 0.75rem 2rem;
}
[data-slot="cost-detail"] {
display: flex;
gap: 1rem;
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--color-text-weak);
}
[data-slot="cost-bar-wrap"] {
height: 0.375rem;
background: var(--color-border-weak);
border-radius: 999px;
}
[data-slot="cost-bar"] {
display: block;
height: 100%;
background: var(--color-background-strong);
border-radius: 999px;
min-width: 2px;
}
[data-slot="pricing-model"] {
color: var(--color-text-strong);
font-weight: 500;
font-size: 0.9375rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="pricing-effective"] {
color: var(--color-text-strong);
font-weight: 600;
}
[data-slot="tps-cell"] {
position: relative;
display: flex;
align-items: center;
height: 0.5rem;
background: var(--color-border-weak);
border-radius: 999px;
cursor: default;
&:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 0.375rem);
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-strong);
white-space: nowrap;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
z-index: 10;
}
}
[data-slot="tps-bar"] {
height: 100%;
background: var(--color-background-strong);
border-radius: 999px 0 0 999px;
min-width: 2px;
}
[data-slot="map-wrap"] {
position: relative;
margin-top: 1.5rem;
}
[data-slot="world-map"] {
width: 100%;
height: auto;
path {
cursor: default;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
}
}
}

View File

@@ -1,920 +0,0 @@
import "./zen/index.css"
import "./rankings.css"
import { Title } from "@solidjs/meta"
import { createSignal, For, Show } from "solid-js"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { Legal } from "~/component/legal"
import { LocaleLinks } from "~/component/locale-links"
import { paths as worldPaths } from "./world-paths"
const models = [
{ name: "minimax-m2.5-free", color: "#f25bb2" },
{ name: "big-pickle", color: "#8b5cf6" },
{ name: "mimo-v2-pro-free", color: "#18a57b" },
{ name: "kimi-k2.5-free", color: "#3481cf" },
{ name: "kimi-k2.5", color: "#e4ad27" },
{ name: "minimax-m2.1-free", color: "#ff5a1f" },
{ name: "glm-5-free", color: "#46b786" },
{ name: "gpt-5-nano", color: "#84cc16" },
{ name: "nemotron-3-super-free", color: "#14c2a3" },
{ name: "claude-opus-4-6", color: "#ff7b3d" },
] as const
type Week = {
week: string
segments: { model: string; color: string; tokens: number }[]
total: number
}
const raw: Record<string, Record<string, number>> = {
"jan-27": {
"big-pickle": 368743864804,
"kimi-k2.5-free": 915886375887,
"kimi-k2.5": 39168495089,
"minimax-m2.1-free": 270764664177,
"gpt-5-nano": 41276213376,
},
"feb-3": {
"big-pickle": 520618971808,
"kimi-k2.5-free": 980317788887,
"kimi-k2.5": 92823557285,
"minimax-m2.1-free": 721380871693,
"gpt-5-nano": 74420810088,
"claude-opus-4-6": 49826050233,
},
"feb-10": {
"minimax-m2.5-free": 905276977685,
"big-pickle": 775593605696,
"kimi-k2.5-free": 546100624556,
"kimi-k2.5": 106869700825,
"minimax-m2.1-free": 273479063739,
"glm-5-free": 21944995438,
"gpt-5-nano": 86562724981,
"claude-opus-4-6": 71785580030,
},
"feb-17": {
"minimax-m2.5-free": 1313909105944,
"big-pickle": 1017431400927,
"kimi-k2.5-free": 350112011799,
"kimi-k2.5": 100616295719,
"minimax-m2.1-free": 11269742719,
"glm-5-free": 735726749938,
"gpt-5-nano": 79036772388,
"claude-opus-4-6": 66789922519,
},
"feb-24": {
"minimax-m2.5-free": 1988229542028,
"big-pickle": 1332574843957,
"kimi-k2.5-free": 17964822381,
"kimi-k2.5": 152009783828,
"minimax-m2.1-free": 10376282858,
"glm-5-free": 3848233361,
"gpt-5-nano": 94195723658,
"claude-opus-4-6": 72579249230,
},
"mar-3": {
"minimax-m2.5-free": 1103526073812,
"big-pickle": 864075264151,
"kimi-k2.5": 236732504356,
"gpt-5-nano": 62076480967,
"claude-opus-4-6": 75122544903,
},
"mar-10": {
"minimax-m2.5-free": 1765538911999,
"big-pickle": 1456821813321,
"kimi-k2.5": 270005019814,
"gpt-5-nano": 80064940458,
"nemotron-3-super-free": 226767628611,
"claude-opus-4-6": 121826865344,
},
"mar-17": {
"minimax-m2.5-free": 2258964526070,
"big-pickle": 2157757939980,
"mimo-v2-pro-free": 1393092895745,
"kimi-k2.5": 253277628360,
"gpt-5-nano": 91075743861,
"nemotron-3-super-free": 257336931857,
"claude-opus-4-6": 107180831444,
},
"mar-24": {
"minimax-m2.5-free": 2256417941175,
"big-pickle": 2596072141208,
"mimo-v2-pro-free": 1612680626347,
"kimi-k2.5": 263140655381,
"gpt-5-nano": 130043140955,
"nemotron-3-super-free": 254171104198,
"claude-opus-4-6": 107813898809,
},
}
const totals: Record<string, number> = {
"jan-27": 1954431878631,
"feb-3": 2782873471532,
"feb-10": 2982021636344,
"feb-17": 3898395901667,
"feb-24": 3980255846812,
"mar-3": 2789862168396,
"mar-10": 4672779512356,
"mar-17": 7453410720062,
"mar-24": 8434615944207,
}
const weeks = ["jan-27", "feb-3", "feb-10", "feb-17", "feb-24", "mar-3", "mar-10", "mar-17", "mar-24"]
const labels: Record<string, string> = {
"jan-27": "Jan 27",
"feb-3": "Feb 3",
"feb-10": "Feb 10",
"feb-17": "Feb 17",
"feb-24": "Feb 24",
"mar-3": "Mar 3",
"mar-10": "Mar 10",
"mar-17": "Mar 17",
"mar-24": "Mar 24",
}
const usage: Week[] = weeks.map((key) => {
const data = raw[key]
const total = totals[key]
const named = models.filter((m) => data[m.name]).map((m) => ({ model: m.name, color: m.color, tokens: data[m.name] }))
const rest = total - named.reduce((sum, s) => sum + s.tokens, 0)
return {
week: labels[key],
total,
segments: [...named, { model: "Other", color: "rgba(148, 163, 184, 0.35)", tokens: rest }],
}
})
const max = Math.max(...usage.map((w) => w.total))
const fmt = (n: number) => {
if (n >= 1e12) return `${(n / 1e12).toFixed(1)}T`
return `${(n / 1e9).toFixed(0)}B`
}
const leaderboard = [
{ rank: 1, model: "big-pickle", tokens: 2_596_072_141_208 },
{ rank: 2, model: "minimax-m2.5-free", tokens: 2_256_417_941_175 },
{ rank: 3, model: "mimo-v2-pro-free", tokens: 1_612_680_626_347 },
{ rank: 4, model: "mimo-v2-omni-free", tokens: 313_963_070_777 },
{ rank: 5, model: "kimi-k2.5", tokens: 263_140_655_381 },
{ rank: 6, model: "nemotron-3-super-free", tokens: 254_171_104_198 },
{ rank: 7, model: "minimax-m2.7", tokens: 235_471_532_073 },
{ rank: 8, model: "qwen3.6-plus-free", tokens: 211_645_852_696 },
{ rank: 9, model: "glm-5", tokens: 149_968_059_435 },
{ rank: 10, model: "gpt-5-nano", tokens: 130_043_140_955 },
{ rank: 11, model: "claude-opus-4-6", tokens: 107_813_898_809 },
{ rank: 12, model: "minimax-m2.5", tokens: 91_477_876_330 },
{ rank: 13, model: "gpt-5.4", tokens: 64_216_544_736 },
{ rank: 14, model: "claude-sonnet-4-6", tokens: 53_638_240_479 },
{ rank: 15, model: "gpt-5.3-codex", tokens: 31_928_019_743 },
{ rank: 16, model: "gemini-3-flash", tokens: 14_528_488_448 },
{ rank: 17, model: "claude-haiku-4-5", tokens: 8_921_076_835 },
{ rank: 18, model: "gemini-3.1-pro", tokens: 7_676_935_166 },
{ rank: 19, model: "claude-sonnet-4-5", tokens: 5_051_555_617 },
{ rank: 20, model: "gpt-5.4-mini", tokens: 4_751_125_737 },
]
const providers = [
{ name: "opencode", color: "#8b5cf6" },
{ name: "minimax", color: "#f25bb2" },
{ name: "xiaomi", color: "#ff5a1f" },
{ name: "moonshot", color: "#3481cf" },
{ name: "nvidia", color: "#14c2a3" },
{ name: "openai", color: "#84cc16" },
{ name: "anthropic", color: "#ff7b3d" },
{ name: "zhipu", color: "#46b786" },
{ name: "google", color: "#e4ad27" },
{ name: "alibaba", color: "#ef5db1" },
{ name: "arcee", color: "#2085ec" },
] as const
const shareRaw: Record<string, Record<string, number>> = {
"jan-27": {
moonshot: 957101046076,
opencode: 368743864804,
minimax: 273789245099,
zhipu: 175220173052,
openai: 71428726197,
anthropic: 63795092028,
arcee: 22900709163,
google: 21116481943,
alibaba: 336540269,
},
"feb-3": {
moonshot: 1075728836899,
minimax: 727777460147,
opencode: 520618971808,
anthropic: 126083048915,
openai: 119992167519,
zhipu: 106315324243,
arcee: 70511760645,
google: 35345191581,
alibaba: 500567954,
},
"feb-10": {
minimax: 1195064506114,
opencode: 775593605696,
moonshot: 655693064959,
openai: 126021773769,
anthropic: 114969689993,
zhipu: 66447874989,
google: 27728156767,
arcee: 20502880487,
},
"feb-17": {
minimax: 1340689075939,
opencode: 1017431400927,
zhipu: 753486920157,
moonshot: 453006079914,
anthropic: 136280713749,
openai: 117036878336,
google: 44709966980,
arcee: 35460739820,
},
"feb-24": {
minimax: 2037390464296,
opencode: 1332574843957,
moonshot: 172345287072,
openai: 165404921924,
anthropic: 137007019288,
arcee: 65348467867,
zhipu: 36192647725,
google: 32632757942,
},
"mar-3": {
minimax: 1236589693899,
opencode: 864075264151,
moonshot: 236940261927,
openai: 144296631970,
anthropic: 143388311109,
zhipu: 89420861866,
google: 35499950281,
xiaomi: 24396507662,
arcee: 14674544044,
},
"mar-10": {
minimax: 1919467888084,
opencode: 1456821813321,
xiaomi: 302031698856,
moonshot: 270021430246,
nvidia: 226767628611,
anthropic: 181566633371,
openai: 171713393912,
zhipu: 113460952065,
google: 30109971757,
arcee: 818102133,
},
"mar-17": {
minimax: 2536581988215,
opencode: 2157757939980,
xiaomi: 1708313298084,
nvidia: 257336931857,
moonshot: 253304780525,
openai: 196305815207,
anthropic: 188087354693,
zhipu: 127277270842,
google: 28180238053,
arcee: 265058488,
},
"mar-24": {
opencode: 2596072141208,
minimax: 2583441903425,
xiaomi: 1930671539776,
moonshot: 263142158847,
nvidia: 254171104198,
openai: 242821016755,
alibaba: 211645852696,
anthropic: 179934787304,
zhipu: 150050493012,
google: 22325363704,
arcee: 336333746,
},
}
const share = weeks.map((key) => {
const data = shareRaw[key]
const total = totals[key]
const segs = providers
.filter((p) => data[p.name])
.map((p) => ({
provider: p.name,
color: p.color,
tokens: data[p.name],
pct: (data[p.name] / total) * 100,
}))
return { week: labels[key], total, segments: segs }
})
const pricing: Record<string, { input: number; output: number; cached: number }> = {
"kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.1 },
"minimax-m2.7": { input: 0.3, output: 1.2, cached: 0.06 },
"glm-5": { input: 1.0, output: 3.2, cached: 0.2 },
"claude-opus-4-6": { input: 5.0, output: 25.0, cached: 0.5 },
"minimax-m2.5": { input: 0.3, output: 1.2, cached: 0.03 },
"gpt-5.4": { input: 2.5, output: 15.0, cached: 0.25 },
"claude-sonnet-4-6": { input: 3.0, output: 15.0, cached: 0.3 },
"gpt-5.3-codex": { input: 1.75, output: 14.0, cached: 0.175 },
"gemini-3-flash": { input: 0.5, output: 3.0, cached: 0.05 },
"claude-haiku-4-5": { input: 1.0, output: 5.0, cached: 0.1 },
"gemini-3.1-pro": { input: 2.0, output: 12.0, cached: 0.2 },
"claude-sonnet-4-5": { input: 3.0, output: 15.0, cached: 0.3 },
"gpt-5.4-mini": { input: 0.75, output: 4.5, cached: 0.075 },
}
const effective = (p: { input: number; output: number; cached: number }) =>
p.cached * 0.94 + p.input * 0.06 + p.output * 0.01
const price = (n: number) => (n === 0 ? "Free" : `$${n.toFixed(2)}`)
const modelGroup: Record<string, string> = {
"kimi-k2.5": "moonshot",
"minimax-m2.7": "minimax",
"minimax-m2.5": "minimax",
"glm-5": "zhipu",
"claude-opus-4-6": "anthropic",
"claude-sonnet-4-6": "anthropic",
"claude-sonnet-4-5": "anthropic",
"claude-haiku-4-5": "anthropic",
"gpt-5.4": "openai",
"gpt-5.3-codex": "openai",
"gpt-5.4-mini": "openai",
"gemini-3-flash": "google",
"gemini-3.1-pro": "google",
}
const pricedModels = leaderboard
.filter((r) => !r.model.endsWith("-free") && pricing[r.model])
.sort((a, b) => effective(pricing[a.model]) - effective(pricing[b.model]))
const maxEffective = Math.max(...pricedModels.map((r) => effective(pricing[r.model])))
const grouped = (() => {
const groups: { provider: string; models: typeof pricedModels }[] = []
const seen = new Set<string>()
for (const row of pricedModels) {
const g = modelGroup[row.model] || row.model
if (seen.has(g)) continue
seen.add(g)
const items = pricedModels.filter((r) => (modelGroup[r.model] || r.model) === g)
groups.push({ provider: g, models: items })
}
return groups
})()
const sessions = [
{ model: "gpt-5.4-nano", spend: 56.19, sessions: 2469, tokens: 2_058_320_992 },
{ model: "gpt-5.1-codex-mini", spend: 46.49, sessions: 649, tokens: 1_114_706_819 },
{ model: "minimax-m2.5", spend: 10774.93, sessions: 101862, tokens: 91_512_287_752 },
{ model: "claude-haiku-4-5", spend: 2169.56, sessions: 14896, tokens: 8_965_515_678 },
{ model: "gpt-5.4-mini", spend: 858.63, sessions: 4726, tokens: 4_759_454_622 },
{ model: "minimax-m2.7", spend: 24120.99, sessions: 118523, tokens: 235_267_498_689 },
{ model: "gpt-5.4", spend: 31834.9, sessions: 142909, tokens: 64_146_279_556 },
{ model: "kimi-k2.5", spend: 39724.09, sessions: 150135, tokens: 263_660_128_320 },
{ model: "gemini-3-flash", spend: 2294.28, sessions: 8405, tokens: 14_752_884_651 },
{ model: "glm-5", spend: 37814.87, sessions: 105292, tokens: 150_626_981_063 },
{ model: "claude-sonnet-4-6", spend: 32133.34, sessions: 42237, tokens: 53_550_067_871 },
{ model: "gpt-5.3-codex", spend: 10809.82, sessions: 13888, tokens: 31_907_600_634 },
{ model: "claude-sonnet-4-5", spend: 4061.09, sessions: 4057, tokens: 5_157_076_730 },
{ model: "gemini-3.1-pro", spend: 6257.1, sessions: 5777, tokens: 7_569_358_077 },
{ model: "claude-opus-4-6", spend: 114219.22, sessions: 42550, tokens: 106_575_958_684 },
{ model: "claude-opus-4-5", spend: 3582.53, sessions: 1576, tokens: 3_219_688_476 },
{ model: "gpt-5.4-pro", spend: 6555.13, sessions: 1264, tokens: 200_314_429 },
]
const avg = (r: (typeof sessions)[number]) => r.spend / r.sessions
const avgTokens = (r: (typeof sessions)[number]) => Math.round(r.tokens / r.sessions)
const fmtTokens = (n: number) => {
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`
return String(n)
}
const maxTps = Math.max(...sessions.map((r) => avgTokens(r)))
const countries: Record<string, number> = {
CN: 1_954_638_555_964,
US: 893_134_696_046,
IN: 535_643_693_640,
BR: 382_163_396_828,
DE: 311_242_520_442,
ES: 209_329_692_495,
RU: 201_527_328_150,
HK: 196_512_051_704,
GB: 166_553_586_780,
AR: 162_194_105_984,
SG: 158_456_781_083,
FR: 156_789_513_531,
JP: 152_176_549_930,
NL: 142_283_579_531,
ID: 133_099_866_730,
CA: 130_175_404_155,
IT: 104_164_651_206,
MX: 103_470_313_882,
TW: 99_750_103_092,
CO: 96_782_169_230,
VN: 89_760_456_363,
PL: 87_055_503_038,
TR: 83_380_620_873,
KR: 82_692_257_401,
AU: 82_626_936_402,
PH: 63_600_637_182,
EG: 61_672_925_246,
PT: 55_231_430_725,
BD: 54_398_663_664,
SE: 53_569_291_560,
CL: 52_319_761_865,
TH: 50_343_490_695,
NG: 50_159_406_105,
PK: 49_490_412_870,
FI: 45_825_535_387,
PE: 43_961_712_555,
CH: 43_300_038_112,
MY: 41_561_444_011,
RO: 40_383_208_346,
BE: 38_322_022_514,
MA: 38_272_211_062,
ZA: 38_180_939_069,
UA: 36_032_426_930,
VE: 34_807_973_969,
KE: 34_239_081_597,
IL: 32_705_941_531,
AT: 31_995_376_039,
DZ: 29_533_977_612,
CZ: 28_976_529_740,
NP: 25_623_967_353,
DO: 23_957_780_174,
DK: 22_736_971_721,
EC: 21_934_976_342,
AE: 21_823_728_235,
TN: 21_412_135_454,
NO: 20_735_253_026,
SA: 20_234_711_215,
BY: 18_939_368_533,
GH: 16_936_621_597,
RS: 16_778_051_855,
NZ: 16_319_979_678,
ET: 15_465_960_765,
IE: 14_978_507_068,
BO: 14_833_821_684,
GR: 14_785_265_286,
HU: 14_540_890_452,
UY: 13_804_790_917,
UZ: 13_008_621_109,
LK: 12_735_597_659,
BG: 12_045_851_521,
CR: 11_261_069_513,
PY: 10_614_168_600,
KH: 10_555_315_979,
KZ: 10_159_168_104,
LT: 9_815_793_110,
EE: 9_417_177_100,
UG: 9_165_111_455,
SK: 9_070_826_870,
LV: 8_198_013_338,
IQ: 8_187_011_695,
HR: 7_605_019_662,
CM: 7_377_050_916,
ZW: 7_314_742_432,
YE: 6_747_216_591,
JO: 6_211_747_207,
PA: 6_092_366_683,
QA: 5_955_042_936,
MD: 5_773_585_201,
TZ: 5_625_754_397,
NI: 5_228_293_282,
GE: 5_165_628_063,
SI: 4_955_336_900,
SN: 4_688_066_204,
BA: 4_549_605_753,
BJ: 4_543_143_957,
GT: 4_424_503_835,
HN: 4_405_852_608,
OM: 4_395_319_849,
}
const maxCountry = Math.max(...Object.values(countries))
const names: Record<string, string> = {
CN: "China",
US: "United States",
IN: "India",
BR: "Brazil",
DE: "Germany",
ES: "Spain",
RU: "Russia",
HK: "Hong Kong",
GB: "United Kingdom",
AR: "Argentina",
SG: "Singapore",
FR: "France",
JP: "Japan",
NL: "Netherlands",
ID: "Indonesia",
CA: "Canada",
IT: "Italy",
MX: "Mexico",
TW: "Taiwan",
CO: "Colombia",
VN: "Vietnam",
PL: "Poland",
TR: "Turkey",
KR: "South Korea",
AU: "Australia",
PH: "Philippines",
EG: "Egypt",
PT: "Portugal",
BD: "Bangladesh",
SE: "Sweden",
CL: "Chile",
TH: "Thailand",
NG: "Nigeria",
PK: "Pakistan",
FI: "Finland",
PE: "Peru",
CH: "Switzerland",
MY: "Malaysia",
RO: "Romania",
BE: "Belgium",
MA: "Morocco",
ZA: "South Africa",
UA: "Ukraine",
VE: "Venezuela",
KE: "Kenya",
IL: "Israel",
AT: "Austria",
DZ: "Algeria",
CZ: "Czechia",
NP: "Nepal",
DO: "Dominican Republic",
DK: "Denmark",
EC: "Ecuador",
AE: "UAE",
TN: "Tunisia",
NO: "Norway",
SA: "Saudi Arabia",
BY: "Belarus",
GH: "Ghana",
RS: "Serbia",
NZ: "New Zealand",
ET: "Ethiopia",
IE: "Ireland",
BO: "Bolivia",
GR: "Greece",
HU: "Hungary",
UY: "Uruguay",
UZ: "Uzbekistan",
LK: "Sri Lanka",
BG: "Bulgaria",
CR: "Costa Rica",
PY: "Paraguay",
KH: "Cambodia",
KZ: "Kazakhstan",
LT: "Lithuania",
EE: "Estonia",
UG: "Uganda",
SK: "Slovakia",
LV: "Latvia",
IQ: "Iraq",
HR: "Croatia",
CM: "Cameroon",
ZW: "Zimbabwe",
YE: "Yemen",
JO: "Jordan",
PA: "Panama",
QA: "Qatar",
MD: "Moldova",
TZ: "Tanzania",
NI: "Nicaragua",
GE: "Georgia",
SI: "Slovenia",
SN: "Senegal",
BA: "Bosnia",
BJ: "Benin",
GT: "Guatemala",
HN: "Honduras",
OM: "Oman",
}
const flag = (code: string) => String.fromCodePoint(...[...code].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))
const fill = (code: string) => {
const val = countries[code]
if (!val) return "var(--color-border-weak)"
const t = Math.pow(Math.log(val) / Math.log(maxCountry), 1.5)
const s = 50 + t * 40
const l = 92 - t * 60
return `hsl(220, ${s}%, ${l}%)`
}
export default function Rankings() {
const [active, setActive] = createSignal<number | null>(null)
const [pos, setPos] = createSignal({ x: 0, y: 0 })
const [expanded, setExpanded] = createSignal(false)
const [shareActive, setShareActive] = createSignal<number | null>(null)
const [sharePos, setSharePos] = createSignal({ x: 0, y: 0 })
const [mapHover, setMapHover] = createSignal<string | null>(null)
const [mapPos, setMapPos] = createSignal({ x: 0, y: 0 })
return (
<main data-page="zen" data-route="rankings">
<Title>Model Rankings</Title>
<LocaleLinks path="/rankings" />
<div data-component="container">
<Header zen hideGetStarted />
<div data-component="content">
<section data-component="hero" data-variant="rankings">
<div data-slot="hero-copy">
<h1>Model Rankings</h1>
<p>
See which models are winning real usage, how the mix shifts over time, and where momentum is moving each
week.
</p>
</div>
</section>
<section data-component="rankings-section">
<h3>Usage</h3>
<div data-slot="chart">
<div
data-slot="bars"
onMouseLeave={() => setActive(null)}
onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}
>
<For each={usage}>
{(week, i) => (
<div
data-slot="bar-group"
data-active={active() === i() ? "" : undefined}
data-dimmed={active() !== null && active() !== i() ? "" : undefined}
onMouseEnter={() => setActive(i())}
>
<span data-slot="bar-label">{fmt(week.total)}</span>
<div data-slot="bar-wrap">
<div data-slot="bar" style={{ height: `${(week.total / max) * 100}%` }}>
<For each={week.segments}>
{(seg) => (
<span
style={{
flex: String(seg.tokens),
background: seg.color,
}}
/>
)}
</For>
</div>
</div>
<span data-slot="bar-week">{week.week}</span>
</div>
)}
</For>
</div>
<Show when={active() !== null}>
<div
data-slot="tooltip"
style={{
left: `${pos().x + 16}px`,
top: `${pos().y - 16}px`,
}}
>
<strong>{usage[active()!].week}</strong>
<span data-slot="tooltip-total">{fmt(usage[active()!].total)} total</span>
<For each={usage[active()!].segments.filter((s) => s.tokens > 0)}>
{(seg) => (
<span data-slot="tooltip-row">
<i style={{ background: seg.color }} />
<span>{seg.model}</span>
<span>{fmt(seg.tokens)}</span>
</span>
)}
</For>
</div>
</Show>
<div data-slot="legend">
<For each={models}>
{(m) => (
<span data-slot="legend-item">
<i style={{ background: m.color }} />
{m.name}
</span>
)}
</For>
<span data-slot="legend-item">
<i style={{ background: "rgba(148, 163, 184, 0.35)" }} />
Other
</span>
</div>
</div>
</section>
<section data-component="rankings-section">
<h3>Leaderboard</h3>
<p data-slot="subtitle">Week of Mar 24 top models by token usage.</p>
<div data-slot="lb-podium">
<For each={leaderboard.slice(0, 3)}>
{(row) => (
<div data-slot="lb-podium-item" data-rank={row.rank}>
<span data-slot="lb-podium-rank">{row.rank}</span>
<strong>{row.model}</strong>
<span data-slot="lb-podium-tokens">{fmt(row.tokens)}</span>
</div>
)}
</For>
</div>
<div data-slot="leaderboard">
<For
each={[
leaderboard.slice(3, expanded() ? 13 : 8),
leaderboard.slice(expanded() ? 13 : 8, expanded() ? leaderboard.length : 13),
]}
>
{(col) => (
<div data-slot="lb-col">
<For each={col}>
{(row) => (
<article data-slot="lb-row">
<span data-slot="lb-rank">{row.rank}.</span>
<div data-slot="lb-info">
<strong>{row.model}</strong>
</div>
<span data-slot="lb-tokens">{fmt(row.tokens)}</span>
</article>
)}
</For>
</div>
)}
</For>
</div>
<Show when={leaderboard.length > 13}>
<button data-slot="lb-toggle" onClick={() => setExpanded(!expanded())}>
{expanded() ? "Show less" : "Show all"}
</button>
</Show>
</section>
<section data-component="rankings-section">
<h3>Market Share</h3>
<div data-slot="chart">
<div
data-slot="share-bars"
onMouseLeave={() => setShareActive(null)}
onMouseMove={(e) => setSharePos({ x: e.clientX, y: e.clientY })}
>
<For each={share}>
{(week, i) => (
<div
data-slot="share-col"
data-dimmed={shareActive() !== null && shareActive() !== i() ? "" : undefined}
onMouseEnter={() => setShareActive(i())}
>
<div data-slot="share-stack">
<For each={week.segments}>
{(seg) => <span style={{ flex: String(seg.pct), background: seg.color }} />}
</For>
</div>
<span data-slot="share-week">{week.week}</span>
</div>
)}
</For>
</div>
<Show when={shareActive() !== null}>
<div
data-slot="tooltip"
style={{
left: `${sharePos().x + 16}px`,
top: `${sharePos().y - 16}px`,
}}
>
<strong>{share[shareActive()!].week}</strong>
<span data-slot="tooltip-total">{fmt(share[shareActive()!].total)} total</span>
<For each={share[shareActive()!].segments.filter((s) => s.tokens > 0)}>
{(seg) => (
<span data-slot="tooltip-row">
<i style={{ background: seg.color }} />
<span>{seg.provider}</span>
<span>{seg.pct.toFixed(1)}%</span>
</span>
)}
</For>
</div>
</Show>
<div data-slot="legend">
<For each={providers}>
{(p) => (
<span data-slot="legend-item">
<i style={{ background: p.color }} />
{p.name}
</span>
)}
</For>
</div>
</div>
</section>
<section data-component="rankings-section">
<h3>Token Cost</h3>
<p data-slot="subtitle">Price per 1M tokens on OpenCode Zen.</p>
<div data-slot="cost-list">
<For each={grouped}>
{(group) => (
<div data-slot="cost-group">
<For each={group.models}>
{(row) => {
const p = pricing[row.model]
const eff = effective(p)
return (
<div data-slot="cost-row">
<div data-slot="cost-main">
<span data-slot="pricing-model">{row.model}</span>
<span data-slot="pricing-effective">{price(eff)}</span>
<span data-slot="cost-bar-wrap">
<span data-slot="cost-bar" style={{ width: `${(eff / maxEffective) * 100}%` }} />
</span>
</div>
<div data-slot="cost-detail">
<span>Input {price(p.input)}</span>
<span>Output {price(p.output)}</span>
<span>Cached {price(p.cached)}</span>
</div>
</div>
)
}}
</For>
</div>
)}
</For>
</div>
</section>
<section data-component="rankings-section">
<h3>Session Cost</h3>
<p data-slot="subtitle">Average cost per session on OpenCode Zen.</p>
<div data-slot="pricing-table">
<div data-slot="pricing-header" data-cols="3">
<span>Model</span>
<span>Cost / Session</span>
<span>Tokens / Session</span>
</div>
<For each={sessions}>
{(row) => (
<div data-slot="pricing-row" data-cols="3">
<span data-slot="pricing-model">{row.model}</span>
<span data-slot="pricing-effective">${avg(row).toFixed(4)}</span>
<span data-slot="tps-cell" title={fmtTokens(avgTokens(row))}>
<span data-slot="tps-bar" style={{ width: `${(avgTokens(row) / maxTps) * 100}%` }} />
</span>
</div>
)}
</For>
</div>
</section>
<section data-component="rankings-section">
<h3>Token by Country</h3>
<div data-slot="map-wrap" onMouseLeave={() => setMapHover(null)}>
<svg
data-slot="world-map"
viewBox="30.767 241.591 784.077 458.627"
onMouseMove={(e) => setMapPos({ x: e.clientX, y: e.clientY })}
>
<For each={Object.entries(worldPaths)}>
{([code, d]) => (
<path
d={d}
fill={fill(code)}
stroke="var(--color-background)"
stroke-width="0.5"
onMouseEnter={() => setMapHover(code)}
onMouseLeave={() => setMapHover(null)}
/>
)}
</For>
</svg>
<Show when={mapHover() && countries[mapHover()!]}>
<div
data-slot="tooltip"
style={{
left: `${mapPos().x + 16}px`,
top: `${mapPos().y - 16}px`,
}}
>
<strong>
{flag(mapHover()!)} {names[mapHover()!] || mapHover()}
</strong>
<span data-slot="tooltip-total">{fmt(countries[mapHover()!])}</span>
</div>
</Show>
</div>
</section>
<Footer />
</div>
</div>
<Legal />
</main>
)
}

File diff suppressed because one or more lines are too long

View File

@@ -100,6 +100,7 @@ export async function handler(
session: sessionId,
request: requestId,
client: ocClient,
...(model === "mimo-v2-pro-free" && JSON.stringify(body).length < 1000 ? { payload: JSON.stringify(body) } : {}),
})
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
@@ -403,6 +404,14 @@ export async function handler(
}),
)
if (modelData.trialEnded)
throw new ModelError(
`${t("zen.api.error.trialEnded", {
model: modelData.name,
link: "https://opencode.ai/go",
})}`,
)
logger.metric({ model: modelId })
return { id: modelId, ...modelData }

View File

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

View File

@@ -27,6 +27,7 @@ export namespace ZenData {
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProviders: z.array(z.string()).optional(),
trialEnded: z.boolean().optional(),
fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
providers: z.array(
@@ -54,7 +55,10 @@ export namespace ZenData {
const ModelsSchema = z.object({
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
liteModels: z.record(z.string(), ModelSchema),
liteModels: z.record(
z.string(),
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
),
providers: z.record(z.string(), ProviderSchema),
})

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
@@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const envs = {
const env = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
@@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const shell = process.platform === "win32" ? null : getUserShell()
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
const { cmd, cmdArgs } = buildCommand(args, envs)
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
return false
}
function buildCommand(args: string, env: Record<string, string>) {
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
}
const sidecar = getSidecarPath()
const shell = process.env.SHELL || "/bin/sh"
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
const user = shell || getUserShell()
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
return { cmd: user, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
describe("shell env", () => {
test("parseShellEnv supports null-delimited pairs", () => {
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
expect(env.PATH).toBe("/usr/bin:/bin")
expect(env.FOO).toBe("bar=baz")
})
test("parseShellEnv ignores invalid entries", () => {
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
expect(Object.keys(env).length).toBe(1)
expect(env.OK).toBe("1")
})
test("mergeShellEnv keeps explicit overrides", () => {
const env = mergeShellEnv(
{
PATH: "/shell/path",
HOME: "/tmp/home",
},
{
PATH: "/desktop/path",
OPENCODE_CLIENT: "desktop",
},
)
expect(env.PATH).toBe("/desktop/path")
expect(env.HOME).toBe("/tmp/home")
expect(env.OPENCODE_CLIENT).toBe("desktop")
})
test("isNushell handles path and binary name", () => {
expect(isNushell("nu")).toBe(true)
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
expect(isNushell("/bin/zsh")).toBe(false)
})
})

View File

@@ -0,0 +1,88 @@
import { spawnSync } from "node:child_process"
import { basename } from "node:path"
const SHELL_ENV_TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
export function getUserShell() {
return process.env.SHELL || "/bin/sh"
}
export function parseShellEnv(out: Buffer) {
const env: Record<string, string> = {}
for (const line of out.toString("utf8").split("\0")) {
if (!line) continue
const ix = line.indexOf("=")
if (ix <= 0) continue
env[line.slice(0, ix)] = line.slice(ix + 1)
}
return env
}
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
const out = spawnSync(shell, [mode, "-c", "env -0"], {
stdio: ["ignore", "pipe", "ignore"],
timeout: SHELL_ENV_TIMEOUT,
windowsHide: true,
})
const err = out.error as NodeJS.ErrnoException | undefined
if (err) {
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
return { type: "Unavailable" }
}
if (out.status !== 0) {
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
return { type: "Unavailable" }
}
const env = parseShellEnv(out.stdout)
if (Object.keys(env).length === 0) {
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
return { type: "Unavailable" }
}
return { type: "Loaded", value: env }
}
export function isNushell(shell: string) {
const name = basename(shell).toLowerCase()
const raw = shell.toLowerCase()
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
export function loadShellEnv(shell: string) {
if (isNushell(shell)) {
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
return null
}
const interactive = probeShellEnv(shell, "-il")
if (interactive.type === "Loaded") {
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value
}
if (interactive.type === "Timeout") {
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
return null
}
const login = probeShellEnv(shell, "-l")
if (login.type === "Loaded") {
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value
}
console.warn(`[cli] Falling back to app environment: ${shell}`)
return null
}
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
return {
...(shell || {}),
...env,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.9",
"version": "1.3.13",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -53,6 +53,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@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",
@@ -94,6 +95,7 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -102,8 +104,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.92",
"@opentui/solid": "0.1.92",
"@opentui/core": "0.1.95",
"@opentui/solid": "0.1.95",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -145,6 +147,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
@@ -48,6 +49,7 @@ await Bun.build({
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_CHANNEL: `'${Script.channel}'`,
},
})

View File

@@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system.
- Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
## TUI config
@@ -88,6 +89,8 @@ export default plugin
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
- `package.json` `main` is only used for server plugin entrypoint resolution.
- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure).
- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state.
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used.
@@ -100,7 +103,18 @@ export default plugin
## Package manifest and install
Package manifest is read from `package.json` field `oc-plugin`.
Install target detection is inferred from `package.json` entrypoints and theme metadata:
- `server` target when `exports["./server"]` exists or `main` is set.
- `tui` target when `exports["./tui"]` exists.
- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths.
`oc-themes` rules:
- `oc-themes` is an array of relative paths.
- Absolute paths and `file://` paths are rejected.
- Resolved theme paths must stay inside the package directory.
- Invalid `oc-themes` causes manifest read failure for install.
Example:
@@ -108,14 +122,20 @@ Example:
{
"name": "@acme/opencode-plugin",
"type": "module",
"main": "./dist/index.js",
"main": "./dist/server.js",
"exports": {
"./server": {
"import": "./dist/server.js",
"config": { "custom": true }
},
"./tui": {
"import": "./dist/tui.js",
"config": { "compact": true }
}
},
"engines": {
"opencode": "^1.0.0"
},
"oc-plugin": [
["server", { "custom": true }],
["tui", { "compact": true }]
]
}
}
```
@@ -144,12 +164,16 @@ npm plugins can declare a version compatibility range in `package.json` using th
- Local installs resolve target dir inside `patchPluginConfig`.
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
- Without `--force`, an already-configured npm package name is a no-op.
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
- Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both.
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
@@ -275,9 +299,12 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename.
- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path.
- Theme copy/write runs under cross-process lock key `tui-theme:<dest>`.
- First install writes only when the destination file is missing.
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored.
@@ -314,10 +341,10 @@ Slot notes:
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries.
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
- `api.lifecycle.signal` is aborted before cleanup runs.
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
@@ -344,7 +371,11 @@ Metadata is persisted by plugin id.
- External TUI plugins load from `tuiConfig.plugin`.
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
- External plugin resolution and import are parallel.
- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules.
- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins.
- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning.
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`.
- File plugins that fail initially are retried once after waiting for config dependency installation.
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
@@ -387,6 +418,7 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
- Manager install uses `api.plugins.install(spec, { global })`.
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
- If runtime add fails, TUI shows a warning and restart remains the fallback.

View File

@@ -1,4 +1,4 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service"
@@ -119,6 +119,11 @@ class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefres
}) {}
const clientId = "opencode-cli"
const eagerRefreshThreshold = Duration.minutes(5)
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
const mapAccountServiceError =
(message = "Account service operation failed") =>
@@ -175,9 +180,8 @@ export namespace Account {
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
@@ -208,6 +212,34 @@ export namespace Account {
return parsed.access_token
})
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (accountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) {
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
}
const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(account.token_expiry, now)) {
return account.access_token
}
return yield* refreshToken(account)
}),
})
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(row.token_expiry, now)) {
return row.access_token
}
return yield* Cache.get(refreshTokenCache, row.id)
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()

View File

@@ -75,6 +75,7 @@ export namespace Agent {
const config = yield* Config.Service
const auth = yield* Auth.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
@@ -330,9 +331,9 @@ export namespace Agent {
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
@@ -393,6 +394,7 @@ export namespace Agent {
)
export const defaultLayer = layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),

View File

@@ -12,3 +12,4 @@ Focus on information that would be helpful for continuing the conversation, incl
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
Do not respond to any questions in the conversation, only output the summary.
Respond in the same language the user used in the conversation.

View File

@@ -1,128 +0,0 @@
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 { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { online, proxied } from "@/util/network"
import { Process } from "../util/process"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(cmd: string[], options?: Process.RunOptions) {
const full = [which(), ...cmd]
log.info("running", {
cmd: full,
...options,
})
const result = await Process.run(full, {
cwd: options?.cwd,
abort: options?.abort,
kill: options?.kill,
timeout: options?.timeout,
nothrow: options?.nothrow,
env: {
...process.env,
...options?.env,
BUN_BE_BUN: "1",
},
})
log.info("done", {
code: result.code,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
return result
}
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") {
if (!online()) return mod
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!stale) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
} else if (cachedVersion === version) {
return mod
}
// 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,50 +0,0 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { online } from "@/util/network"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
function which() {
return process.execPath
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
if (!online()) {
log.debug("offline, skipping bun info", { pkg, field })
return null
}
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
cwd,
env: {
...process.env,
BUN_BE_BUN: "1",
},
nothrow: true,
})
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
return null
}
const value = stdout.toString().trim()
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

@@ -46,7 +46,7 @@ export namespace Bus {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cache = yield* InstanceState.make<State>(
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
@@ -82,16 +82,17 @@ export namespace Bus {
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = state.typed.get(def.type)
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(state.wildcard, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
GlobalBus.emit("event", {
directory: Instance.directory,
directory: dir,
payload,
})
})
@@ -101,8 +102,8 @@ export namespace Bus {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
const ps = yield* getOrCreate(state, def)
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
@@ -112,8 +113,8 @@ export namespace Bus {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
return Stream.fromPubSub(state.wildcard)
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
@@ -149,14 +150,14 @@ export namespace Bus {
def: D,
callback: (event: Payload<D>) => unknown,
) {
const state = yield* InstanceState.get(cache)
const ps = yield* getOrCreate(state, def)
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const state = yield* InstanceState.get(cache)
return yield* on(state.wildcard, "*", callback)
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })

View File

@@ -114,8 +114,10 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
dep.log.info(
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
)
return false
}

View File

@@ -125,6 +125,7 @@ import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
@@ -250,7 +251,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
@@ -299,7 +299,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!renderer.getSelection()) return
const sel = renderer.getSelection()
if (!sel) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
@@ -323,6 +324,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
return
}
renderer.clearSelection()
})

View File

@@ -4,6 +4,7 @@ import { Clipboard } from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
export function ErrorComponent(props: {
error: Error
@@ -82,7 +83,7 @@ export function ErrorComponent(props: {
<text fg={colors.bg}>Exit</text>
</box>
</box>
<scrollbox height={Math.floor(term().height * 0.7)}>
<scrollbox height={Math.floor(term().height * 0.7)} scrollAcceleration={getScrollAcceleration()}>
<text fg={colors.muted}>{props.error.stack}</text>
</scrollbox>
<text fg={colors.text}>{props.error.message}</text>

View File

@@ -6,6 +6,8 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -81,6 +83,7 @@ export function Autocomplete(props: {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const [store, setStore] = createStore({
index: 0,
@@ -605,6 +608,7 @@ export function Autocomplete(props: {
})
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<box
@@ -622,6 +626,7 @@ export function Autocomplete(props: {
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
<Index
each={options()}

View File

@@ -57,7 +57,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current)!
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
},
set(name: string) {
if (!agents().some((x) => x.name === name))

View File

@@ -18,7 +18,14 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
import {
readPackageThemes,
readPluginId,
readV1Plugin,
resolvePluginId,
type PluginPackage,
type PluginSource,
} from "@/plugin/shared"
import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
@@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Flock } from "@/util/flock"
import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
@@ -39,8 +47,9 @@ type PluginLoad = {
source: PluginSource | "internal"
id: string
module: TuiPluginModule
theme_meta: TuiConfig.PluginMeta
origin: Config.PluginOrigin
theme_root: string
theme_files: string[]
}
type Api = HostPluginApi
@@ -67,12 +76,15 @@ type RuntimeState = {
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<string, TuiConfig.PluginRecord>
pending: Map<string, Config.PluginOrigin>
}
const log = Log.create({ service: "tui.plugin" })
const DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled"
const EMPTY_TUI: TuiPluginModule = {
tui: async () => {},
}
function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) {
@@ -87,6 +99,11 @@ function fail(message: string, data: Record<string, unknown>) {
console.error(`[tui.plugin] ${text}`, next)
}
function warn(message: string, data: Record<string, unknown>) {
log.warn(message, data)
console.warn(`[tui.plugin] ${message}`, data)
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -129,7 +146,7 @@ function resolveRoot(root: string) {
}
function createThemeInstaller(
meta: TuiConfig.PluginMeta,
meta: Config.PluginOrigin,
root: string,
spec: string,
plugin: PluginEntry,
@@ -148,153 +165,73 @@ function createThemeInstaller(
const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
const exists = hasTheme(name)
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") return
if (!prev) {
if (await Filesystem.exists(dest)) {
plugin.themes[name] = {
src,
dest,
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
}
return
}
if (prev.dest !== dest) return
if (prev.mtime === mtime && prev.size === size) return
}
const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
if (text === undefined) return
const fail = Symbol()
const data = await Promise.resolve(text)
.then((x) => JSON.parse(x))
.catch((error) => {
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
return fail
})
if (data === fail) return
if (!isTheme(data)) {
log.warn("invalid tui plugin theme", { path: spec, theme: src })
return
}
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
}
upsertTheme(name, data)
plugin.themes[name] = {
const info = {
src,
dest,
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
await Flock.withLock(`tui-theme:${dest}`, async () => {
const save = async () => {
plugin.themes[name] = info
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
}
const exists = hasTheme(name)
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") {
if (!prev && (await Filesystem.exists(dest))) {
await save()
}
return
}
if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
}
const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
if (text === undefined) return
const fail = Symbol()
const data = await Promise.resolve(text)
.then((x) => JSON.parse(x))
.catch((error) => {
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
return fail
})
if (data === fail) return
if (!isTheme(data)) {
log.warn("invalid tui plugin theme", { path: spec, theme: src })
return
}
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
}
upsertTheme(name, data)
await save()
}).catch((error) => {
log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
})
}
}
async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
const plan = PluginLoader.plan(cfg.item)
if (plan.deprecated) return
log.info("loading tui plugin", { path: plan.spec, retry })
const resolved = await PluginLoader.resolve(plan, "tui")
if (!resolved.ok) {
if (resolved.stage === "install") {
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
return
}
if (resolved.stage === "compatibility") {
fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
return
}
fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
return
}
const loaded = await PluginLoader.load(resolved.value)
if (!loaded.ok) {
fail("failed to load tui plugin", {
path: plan.spec,
target: resolved.value.entry,
retry,
error: loaded.error,
})
return
}
const mod = await Promise.resolve()
.then(() => {
return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
})
.catch((error) => {
fail("failed to load tui plugin", {
path: plan.spec,
target: loaded.value.entry,
retry,
error,
})
return
})
if (!mod) return
const id = await resolvePluginId(
loaded.value.source,
plan.spec,
loaded.value.target,
readPluginId(mod.id, plan.spec),
loaded.value.pkg,
).catch((error) => {
fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
return
})
if (!id) return
return {
options: plan.options,
spec: plan.spec,
target: loaded.value.target,
retry,
source: loaded.value.source,
id,
module: mod,
theme_meta: {
scope: cfg.scope,
source: cfg.source,
},
theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
}
}
function createMeta(
source: PluginLoad["source"],
spec: string,
@@ -336,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal",
id: item.id,
module: item,
theme_meta: {
origin: {
spec,
scope: "global",
source: target,
},
theme_root: process.cwd(),
theme_files: [],
}
}
async function readThemeFiles(spec: string, pkg?: PluginPackage) {
if (!pkg) return [] as string[]
return Promise.resolve()
.then(() => readPackageThemes(spec, pkg))
.catch((error) => {
warn("invalid tui plugin oc-themes", {
path: spec,
pkg: pkg.pkg,
error,
})
return [] as string[]
})
}
async function syncPluginThemes(plugin: PluginEntry) {
if (!plugin.load.theme_files.length) return
if (plugin.meta.state === "same") return
const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
for (const file of plugin.load.theme_files) {
await install(file).catch((error) => {
warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
})
}
}
@@ -475,6 +439,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve()
.then(async () => {
await syncPluginThemes(plugin)
await plugin.plugin(api, plugin.load.options, plugin.meta)
return true
})
@@ -541,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
}
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
})
const event: TuiPluginApi["event"] = {
@@ -623,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
const ready: PluginLoad[] = []
let deps: Promise<void> | undefined
for (let i = 0; i < list.length; i++) {
let entry = loaded[i]
if (!entry) {
const item = list[i]
if (!item) continue
if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
deps ??= wait().catch((error) => {
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
return PluginLoader.loadExternal({
items: list,
kind: "tui",
wait: async () => {
await wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
entry = await loadExternalPlugin(item, true)
}
if (!entry) continue
ready.push(entry)
}
},
finish: async (loaded, origin, retry) => {
const mod = await Promise.resolve()
.then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
.catch((error) => {
fail("failed to load tui plugin", {
path: loaded.spec,
target: loaded.entry,
retry,
error,
})
return
})
if (!mod) return
return ready
const id = await resolvePluginId(
loaded.source,
loaded.spec,
loaded.target,
readPluginId(mod.id, loaded.spec),
loaded.pkg,
).catch((error) => {
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
return
})
if (!id) return
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
return {
options: loaded.options,
spec: loaded.spec,
target: loaded.target,
retry,
source: loaded.source,
id,
module: mod,
origin,
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
theme_files,
}
},
missing: async (loaded, origin, retry) => {
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
if (!theme_files.length) return
const name =
typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0
? loaded.pkg.json.name.trim()
: undefined
const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => {
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
return
})
if (!id) return
return {
options: loaded.options,
spec: loaded.spec,
target: loaded.target,
retry,
source: loaded.source,
id,
module: EMPTY_TUI,
origin,
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
theme_files,
}
},
report: {
start(candidate, retry) {
log.info("loading tui plugin", { path: candidate.plan.spec, retry })
},
missing(candidate, retry, message) {
warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
},
error(candidate, retry, stage, error, resolved) {
const spec = candidate.plan.spec
if (stage === "install") {
fail("failed to resolve tui plugin", { path: spec, retry, error })
return
}
if (stage === "compatibility") {
fail("tui plugin incompatible", { path: spec, retry, error })
return
}
if (stage === "entry") {
fail("failed to resolve tui plugin entry", { path: spec, retry, error })
return
}
fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error })
},
},
})
}
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
@@ -678,12 +723,12 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
})
}
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
const plugin: PluginEntry = {
id: entry.id,
load: entry,
meta: row,
meta: info,
themes,
plugin: entry.module.tui,
enabled: true,
@@ -698,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok }
}
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
return {
item: spec,
spec,
scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
}
@@ -738,8 +783,8 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
const spec = raw.trim()
if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
const next = Config.pluginSpecifier(cfg.item)
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
const next = Config.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
@@ -753,7 +798,6 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
return [] as PluginLoad[]
})
if (!ready.length) {
fail("failed to add tui plugin", { path: next })
return false
}
@@ -824,7 +868,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") {
return {
ok: false,
message: `"${spec}" does not declare supported targets in package.json`,
message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`,
}
}
@@ -859,9 +903,9 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file
const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
state.pending.set(spec, {
item,
spec: next,
scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
})
@@ -946,9 +990,9 @@ export namespace TuiPluginRuntime {
directory: cwd,
fn: async () => {
const config = await TuiConfig.get()
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {

View File

@@ -19,17 +19,17 @@ import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import {
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
TextAttributes,
RGBA,
} from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import type {
AssistantMessage,
Part,
Provider,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
@@ -77,22 +77,14 @@ import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
addDefaultParsers(parsers.parsers)
class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
tick(_now?: number): number {
return this.speed
}
reset(): void {}
}
const context = createContext<{
width: number
sessionID: string
@@ -102,6 +94,7 @@ const context = createContext<{
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
providers: () => ReadonlyMap<string, Provider>
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
@@ -167,18 +160,9 @@ export function Session() {
})
const showTimestamps = createMemo(() => timestamps() === "show")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => {
const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
if (tui?.scroll_speed) {
return new CustomSpeedScroll(tui.scroll_speed)
}
return new CustomSpeedScroll(3)
})
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
createEffect(() => {
if (session()?.workspaceID) {
@@ -376,6 +360,11 @@ export function Session() {
dialog.clear()
return
}
if (!kv.get("share_consent", false)) {
const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?")
if (ok !== true) return
kv.set("share_consent", true)
}
await sdk.client.session
.share({
sessionID: route.sessionID,
@@ -841,6 +830,7 @@ export function Session() {
thinking: showThinking(),
toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(),
providers: sync.data.provider,
},
)
await Clipboard.copy(transcript)
@@ -885,6 +875,7 @@ export function Session() {
thinking: options.thinking,
toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata,
providers: sync.data.provider,
},
)
@@ -1030,6 +1021,7 @@ export function Session() {
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
@@ -1314,10 +1306,12 @@ function UserMessage(props: {
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const ctx = use()
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
@@ -1387,7 +1381,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
{" "}
</span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>

View File

@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -62,12 +63,14 @@ function EditBody(props: { request: PermissionRequest }) {
})
const ft = createMemo(() => filetype(filepath()))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
return (
<box flexDirection="column" gap={1}>
<Show when={diff()}>
<scrollbox
height="100%"
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,

View File

@@ -1,13 +1,18 @@
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { Installation } from "@/installation"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<Show when={session()}>
@@ -23,6 +28,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
>
<scrollbox
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,

View File

@@ -10,6 +10,8 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
export interface DialogSelectProps<T> {
title: string
@@ -50,6 +52,9 @@ export type DialogSelectRef<T> = {
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({
selected: 0,
filter: "",
@@ -276,6 +281,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>

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