mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-21 16:17:35 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d515ae5f89 | ||
|
|
788eb0fa29 | ||
|
|
58fcad77a8 | ||
|
|
c555559ac1 | ||
|
|
e3a3fa7108 | ||
|
|
6238af397e | ||
|
|
7ffd75faf6 | ||
|
|
1f8ab95695 | ||
|
|
62dc1f7696 | ||
|
|
66f10ab7bf | ||
|
|
1ca8f63a79 | ||
|
|
03bcdd580d | ||
|
|
cc502f7e5f | ||
|
|
64ce8771c0 | ||
|
|
702a73d91a | ||
|
|
991b727eb8 | ||
|
|
ba342ce227 | ||
|
|
1464545665 | ||
|
|
fecacc9e68 | ||
|
|
1f73b4806b | ||
|
|
da2ce02596 | ||
|
|
25f35dcfb8 | ||
|
|
717f81ce08 | ||
|
|
5fdfcc7a80 | ||
|
|
0530c8e512 | ||
|
|
1d8cf4564b | ||
|
|
7e88f6bb18 | ||
|
|
af592fb779 | ||
|
|
7fc3f68007 | ||
|
|
cdcbb0047e | ||
|
|
a1956a7522 | ||
|
|
55bc7fd403 | ||
|
|
3049b1e684 | ||
|
|
1f36a7aff8 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -294,7 +294,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -318,7 +318,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -720,7 +720,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -775,7 +775,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -809,7 +809,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -828,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -874,7 +874,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1032,7 +1032,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -265,6 +265,8 @@ export interface ParserState {
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: string
|
||||
/** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
|
||||
readonly reasoningTextObserved: boolean
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
readonly reasoningDetailsObserved: boolean
|
||||
readonly reasoningEmitted: boolean
|
||||
@@ -344,10 +346,18 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown,
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
|
||||
return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
return observed.map(lowerReasoningDetail)
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details.map(lowerReasoningDetail)
|
||||
}
|
||||
|
||||
// OpenRouter requires its `reasoning.*` entries back unmodified, `index`
|
||||
// included. Kimi rejects its stream-only `index` on requests
|
||||
// ("the reasoning_details ... must not contain streaming index") and Kimi Code
|
||||
// never sends it, so drop it from Kimi entries while leaving every other
|
||||
// dialect untouched.
|
||||
const lowerReasoningDetail = (detail: unknown) =>
|
||||
isKimiDetail(detail) ? Object.fromEntries(Object.entries(detail).filter((entry) => entry[0] !== "index")) : detail
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
@@ -413,6 +423,9 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (observedField !== undefined) return observedField
|
||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||
if (!fullyStructured || requireReasoning) return "reasoning_content"
|
||||
// Kimi always expects `reasoning_content` on replayed assistant messages,
|
||||
// even when thinking arrived only through structured details.
|
||||
if (details?.some(isKimiDetail)) return "reasoning_content"
|
||||
})()
|
||||
const reasoningText = (() => {
|
||||
if (configuredField !== undefined)
|
||||
@@ -887,12 +900,32 @@ const reasoningDelta = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||
// Kimi's OpenAI-compatible surface streams preserved thinking through a
|
||||
// `reasoning_details` shape that is similar to, but not the same as, the
|
||||
// OpenRouter `reasoning.*` dialect: `{ type: "summary", summary }` and
|
||||
// `{ type: "encrypted", encrypted }` entries keyed by a stream-only `index`.
|
||||
// Kimi does not document this publicly; the expected handling is taken from
|
||||
// how Kimi Code (Kimi's own client) behaves: merge summary deltas by `index`,
|
||||
// and replay the entries without `index` alongside `reasoning_content`.
|
||||
const isKimiDetail = (detail: unknown): detail is Record<string, unknown> =>
|
||||
isRecord(detail) && (detail.type === "summary" || detail.type === "encrypted")
|
||||
|
||||
// The field that accumulates across streamed deltas for mergeable detail kinds.
|
||||
const mergeableDetailField = (detail: Record<string, unknown>) => {
|
||||
if (detail.type === "reasoning.text") return "text"
|
||||
if (detail.type === "reasoning.summary" || detail.type === "summary") return "summary"
|
||||
}
|
||||
|
||||
const detailText = (details: ReadonlyArray<unknown>, hideKimiSummary: boolean) => {
|
||||
const text = details.flatMap((detail) => {
|
||||
if (!isRecord(detail)) return []
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
|
||||
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
|
||||
return [detail.summary]
|
||||
// Kimi streams the full thinking through `reasoning_content` and a separate
|
||||
// summary through details; show the summary only when nothing else does.
|
||||
if (detail.type === "summary" && !hideKimiSummary && typeof detail.summary === "string" && detail.summary)
|
||||
return [detail.summary]
|
||||
return []
|
||||
})
|
||||
if (text.length > 0) return text.join("")
|
||||
@@ -901,30 +934,32 @@ const detailText = (details: ReadonlyArray<unknown>) => {
|
||||
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
|
||||
for (const detail of details) {
|
||||
const previous = result.at(-1)
|
||||
const field = isRecord(detail) ? mergeableDetailField(detail) : undefined
|
||||
if (
|
||||
field === undefined ||
|
||||
!isRecord(previous) ||
|
||||
previous.type !== "reasoning.text" ||
|
||||
!isRecord(detail) ||
|
||||
detail.type !== "reasoning.text" ||
|
||||
conflictingReasoningTextDetails(previous, detail)
|
||||
previous.type !== detail.type ||
|
||||
conflictingReasoningDetails(previous, detail)
|
||||
) {
|
||||
result.push(detail)
|
||||
continue
|
||||
}
|
||||
result[result.length - 1] = {
|
||||
const merged = {
|
||||
...previous,
|
||||
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
|
||||
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
|
||||
[field]: `${typeof previous[field] === "string" ? previous[field] : ""}${typeof detail[field] === "string" ? detail[field] : ""}`,
|
||||
signature: mergeDetailValue(previous.signature, detail.signature),
|
||||
format: mergeDetailValue(previous.format, detail.format),
|
||||
}
|
||||
result[result.length - 1] = Object.fromEntries(Object.entries(merged).filter((entry) => entry[1] !== undefined))
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous || current || (previous !== undefined ? previous : current)
|
||||
|
||||
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||
const conflictingReasoningDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||
conflictingDetailValue(previous.id, current.id) ||
|
||||
conflictingDetailValue(previous.index, current.index) ||
|
||||
conflictingDetailValue(previous.format, current.format) ||
|
||||
@@ -999,11 +1034,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}
|
||||
|
||||
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||
const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
const text = detailDelta?.length
|
||||
? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
|
||||
: reasoning?.text
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
reasoningDetailsObserved &&
|
||||
@@ -1099,6 +1137,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
finishReason,
|
||||
lifecycle,
|
||||
reasoningField,
|
||||
reasoningTextObserved,
|
||||
reasoningDetails: state.reasoningDetails,
|
||||
reasoningDetailsObserved,
|
||||
reasoningEmitted,
|
||||
@@ -1179,6 +1218,7 @@ export const protocol = Protocol.make({
|
||||
toolCallEvents: [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningField: request.model.compatibility?.reasoningField,
|
||||
reasoningTextObserved: false,
|
||||
reasoningDetails: [],
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
|
||||
@@ -1208,6 +1208,185 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// Kimi's coding endpoint streams the full thinking through `reasoning_content`
|
||||
// and a separate summary + encrypted blob through its own `reasoning_details`
|
||||
// dialect. The stream-only `index` must not be echoed back.
|
||||
it.effect("merges Kimi summary deltas by index and replays details without the streaming index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_content: "Let me" } }] },
|
||||
{ choices: [{ delta: { reasoning_content: " think" } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const stored = [
|
||||
{ index: 0, type: "summary", summary: "Plan tools" },
|
||||
{ index: 1, type: "encrypted", encrypted: "opaque" },
|
||||
]
|
||||
expect(response.reasoning).toBe("Let me think")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning_content", reasoningDetails: stored },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
|
||||
reasoning_content: "Let me think",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "Plan tools" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("displays Kimi summaries and replays reasoning_content when no scalar reasoning streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Plan tools")
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hello",
|
||||
reasoning_content: "Plan tools",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "Plan tools" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
// Sessions persisted before the fix already hold Kimi details with `index`.
|
||||
it.effect("strips the streaming index from previously stored Kimi details", () =>
|
||||
Effect.gen(function* () {
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
reasoningField: "reasoning_content",
|
||||
reasoningDetails: [
|
||||
{ index: 0, type: "summary", summary: "thinking" },
|
||||
{ index: 1, type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning_content: "thinking",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "thinking" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges consecutive OpenRouter summary deltas and replays them unmodified", () =>
|
||||
Effect.gen(function* () {
|
||||
const merged = [
|
||||
{ type: "reasoning.summary", summary: "Plan tools", format: "openai-responses-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 },
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.summary", summary: "Plan", format: "openai-responses-v1", index: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.summary", summary: " tools", format: "openai-responses-v1", index: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ delta: { reasoning_details: [merged[1]] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Plan tools")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: merged }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses scalar display text for signature-only reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
|
||||
|
||||
@@ -19,6 +19,13 @@ export function mountBrowserPane() {
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
url: undefined as string | undefined,
|
||||
loading: false,
|
||||
generation: 0,
|
||||
delayNavigation: false,
|
||||
pendingURL: undefined as string | undefined,
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
})
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
@@ -47,13 +54,33 @@ export function mountBrowserPane() {
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
|
||||
active: () => {
|
||||
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
|
||||
return {
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[store.session],
|
||||
}
|
||||
},
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => undefined,
|
||||
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: () => undefined,
|
||||
command: (command) => {
|
||||
setStore("error", undefined)
|
||||
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
|
||||
if (command.type === "navigate") {
|
||||
if (store.delayNavigation) {
|
||||
setStore("pendingURL", command.url)
|
||||
return
|
||||
}
|
||||
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
|
||||
}
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -64,6 +91,32 @@ export function mountBrowserPane() {
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
|
||||
}
|
||||
>
|
||||
Load current page
|
||||
</button>
|
||||
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
|
||||
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
|
||||
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
|
||||
Block navigation
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({
|
||||
url: store.pendingURL,
|
||||
pendingURL: undefined,
|
||||
loading: false,
|
||||
generation: store.generation + 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
|
||||
@@ -57,3 +57,153 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await toggle.click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Loading page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await root.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await address.fill("https://unsubmitted.example/")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://blocked.example/")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://blocked.example/")
|
||||
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
|
||||
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
"https://alpha.example/",
|
||||
)
|
||||
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
|
||||
await root.getByRole("button", { name: "Reload", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("")
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("selects the full URL when the address field gains focus", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
await address.pressSequentially("https://example.com/")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await address.focus()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
await address.press("ArrowRight")
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
})
|
||||
|
||||
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -65,14 +65,14 @@
|
||||
}
|
||||
|
||||
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
|
||||
Move only as far as needed; wider panels already have enough space in the margin. */
|
||||
@container session-chat (min-width: 1320px) {
|
||||
When they overlap, center the conversation in the space that remains beside the summary. */
|
||||
@container session-chat (min-width: 1320px) and (width < 1640px) {
|
||||
[data-slot="session-chat-panel"][data-summary-open="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
--session-summary-translate: min(0px, calc(50cqi - 820px));
|
||||
--session-summary-translate: -160px;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-summary-translate: max(0px, calc(820px - 50cqi));
|
||||
--session-summary-translate: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,6 +906,10 @@ export const dict = {
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.empty.title": "Enter URL",
|
||||
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
|
||||
"session.browser.failed.title": "URL can't be reached",
|
||||
"session.browser.failed.description": "Check the URL and your connection, then try again.",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
|
||||
@@ -20,13 +20,18 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const command = useCommand()
|
||||
const state = props.browser.active
|
||||
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
|
||||
const failed = () => !!state()?.loadError
|
||||
const registration = props.browser.registration
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
submitted: false,
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
@@ -43,7 +48,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
title: language.t("command.browser.reload"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "f5",
|
||||
disabled: !props.visible || !state(),
|
||||
disabled: !props.visible || !address(),
|
||||
onSelect: () => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
@@ -71,7 +76,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -107,7 +114,25 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", address()))
|
||||
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
|
||||
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
|
||||
createEffect(
|
||||
on(
|
||||
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
|
||||
() => setStore("navigating", false),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.browser.error(),
|
||||
(error) => {
|
||||
if (error && !store.editing) setStore("address", address())
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(registration, (current) => {
|
||||
// Session routes can change before this pane unmounts. Hide the registration
|
||||
@@ -123,6 +148,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
empty,
|
||||
failed,
|
||||
registration,
|
||||
],
|
||||
() => {
|
||||
@@ -157,7 +184,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
|
||||
@@ -193,7 +220,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
disabled={!state()?.loading && !address()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
@@ -212,8 +239,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
const url = store.address.trim()
|
||||
if (!tab) return
|
||||
if (url || failed()) {
|
||||
setStore({ submitted: true, address: url, navigating: true })
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
|
||||
}
|
||||
event.currentTarget.querySelector("input")?.blur()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -224,8 +256,14 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: address() })}
|
||||
onFocus={(event) => {
|
||||
setStore("editing", true)
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
onBlur={() =>
|
||||
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
|
||||
}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
onScroll={(event) => {
|
||||
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
|
||||
@@ -243,12 +281,27 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<Show when={props.browser.error() && !failed()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<Show when={(empty() || failed()) && !props.browser.suspended()}>
|
||||
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
|
||||
<div
|
||||
dir="auto"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
|
||||
>
|
||||
<Icon name="globe" size="large" class="mb-2 shrink-0" />
|
||||
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
|
||||
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
|
||||
</div>
|
||||
<div class="text-13-regular leading-[var(--line-height-base)]">
|
||||
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.browser.suspended()}>
|
||||
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
|
||||
{language.t("session.browser.suspended")}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
@@ -69,9 +71,19 @@ export function SortableTab(props: {
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onTabClose(props.tab)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] {
|
||||
[data-titlebar-tab],
|
||||
[data-titlebar-tab-action] {
|
||||
--tab-base: var(--v2-background-bg-deep);
|
||||
--tab-overlay: transparent;
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
@@ -68,6 +69,14 @@
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
@@ -351,9 +351,10 @@ export function Titlebar(props: {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
@@ -650,8 +651,9 @@ export function Titlebar(props: {
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { getErrorReported } from "effect/Runtime"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode/util/observability"
|
||||
@@ -130,5 +131,15 @@ Effect.gen(function* () {
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||
NodeRuntime.runMain,
|
||||
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
|
||||
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
|
||||
// channel through which a startup failure's reason reaches the user.
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return
|
||||
if (!getErrorReported(Cause.squash(cause))) return
|
||||
process.stderr.write(Cause.pretty(cause) + "\n")
|
||||
}),
|
||||
),
|
||||
NodeRuntime.runMain({ disableErrorReporting: true }),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -180,7 +180,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
|
||||
is true for every Error value.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -270,8 +271,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
and plain-object results.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
|
||||
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
|
||||
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
|
||||
## Arrays
|
||||
|
||||
@@ -285,7 +286,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`lastIndexOf`.
|
||||
- [x] Aggregation: `reduce` and `reduceRight`.
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
|
||||
`toLocaleString`, holes and nullish elements as empty strings).
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
|
||||
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
|
||||
@@ -308,7 +310,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
|
||||
aliases, which ignore their locale argument and apply the default Unicode casing.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
@@ -332,7 +335,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
|
||||
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
|
||||
`Number.parseInt`, and `Number.parseFloat`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
|
||||
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
|
||||
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
|
||||
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
|
||||
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
|
||||
@@ -379,6 +383,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`TimeClip` behavior.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
|
||||
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
|
||||
their throws.
|
||||
@@ -404,7 +410,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
|
||||
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
|
||||
- [x] `new Map()` from synchronous iterables of entries.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
|
||||
- [x] `new Set()` from synchronous iterables.
|
||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect, Exit } from "effect"
|
||||
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { primitivePrototype } from "./intrinsics.js"
|
||||
import { typeError } from "./model.js"
|
||||
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
@@ -43,6 +44,13 @@ export const toPrimitive = <R>(
|
||||
})
|
||||
}
|
||||
|
||||
/** Invoke(value, name): calls the method the value would find through its prototype. */
|
||||
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
|
||||
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
|
||||
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
|
||||
return ctx.call(get(target, name), value, [])
|
||||
}
|
||||
|
||||
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
|
||||
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
methods(builtins, prototype, [
|
||||
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
|
||||
])
|
||||
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
|
||||
}
|
||||
return ctor
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} from "./model.js"
|
||||
import { checkStringLength } from "./limits.js"
|
||||
import { locate, materialize } from "./errors.js"
|
||||
import type { Builtins } from "./intrinsics.js"
|
||||
import { type Builtins, primitivePrototype } from "./intrinsics.js"
|
||||
import { globals } from "./globals.js"
|
||||
import {
|
||||
assign,
|
||||
@@ -2071,16 +2071,14 @@ class Frame<R> {
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
|
||||
const builtins = self.ctx.builtins
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
return { target: builtins.String, key, receiver: objectValue }
|
||||
}
|
||||
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
|
||||
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
|
||||
const proto = primitivePrototype(self.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
|
||||
@@ -52,6 +52,14 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
|
||||
return value
|
||||
}
|
||||
|
||||
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
|
||||
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
|
||||
if (typeof value === "string") return builtins.String
|
||||
if (typeof value === "number") return builtins.Number
|
||||
if (typeof value === "boolean") return builtins.Boolean
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const createBuiltins = (): Builtins => {
|
||||
const object = new Obj(null)
|
||||
// Function.prototype is itself callable and returns undefined.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interp
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
@@ -340,6 +340,19 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"toLocaleString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
Effect.map(
|
||||
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
|
||||
item === null || item === undefined
|
||||
? Effect.succeed("")
|
||||
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
|
||||
),
|
||||
(parts) => parts.join(","),
|
||||
),
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
|
||||
[
|
||||
|
||||
@@ -145,6 +145,29 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsert",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsert").map
|
||||
if (!target.has(args[0])) target.set(args[0], args[1])
|
||||
return target.get(args[0])
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsertComputed",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsertComputed").map
|
||||
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
|
||||
if (target.has(args[0])) return target.get(args[0])
|
||||
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
|
||||
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
|
||||
target.set(args[0], value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
|
||||
[
|
||||
"clear",
|
||||
|
||||
@@ -105,6 +105,13 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
|
||||
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
|
||||
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
|
||||
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
|
||||
(name): Method => [
|
||||
name,
|
||||
0,
|
||||
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
|
||||
],
|
||||
),
|
||||
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
|
||||
...setters.map(
|
||||
([name, length]): Method => [
|
||||
|
||||
@@ -50,6 +50,7 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
}
|
||||
methods(builtins, builtins.Number, [
|
||||
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
|
||||
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
|
||||
[
|
||||
"toExponential",
|
||||
1,
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
set,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { groupBy } from "./collections.js"
|
||||
@@ -196,7 +196,7 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
true,
|
||||
],
|
||||
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
["toLocaleString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
["toLocaleString", 0, (thisValue) => invoke(ctx, thisValue, "toString", "Object.prototype.toLocaleString")],
|
||||
[
|
||||
"valueOf",
|
||||
0,
|
||||
|
||||
@@ -150,6 +150,8 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
simple("valueOf", 0, (value) => value),
|
||||
simple("toLowerCase", 0, (value) => value.toLowerCase()),
|
||||
simple("toUpperCase", 0, (value) => value.toUpperCase()),
|
||||
simple("toLocaleLowerCase", 0, (value) => value.toLowerCase()),
|
||||
simple("toLocaleUpperCase", 0, (value) => value.toUpperCase()),
|
||||
simple("trim", 0, (value) => value.trim()),
|
||||
simple("trimStart", 0, (value) => value.trimStart()),
|
||||
simple("trimLeft", 0, (value) => value.trimStart()),
|
||||
|
||||
@@ -22,6 +22,16 @@ describe("source syntax", () => {
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("Error.isError is true for every Error value and nothing else", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const caught = (() => { try { null.foo } catch (error) { return error } })()
|
||||
return [Error.isError(new Error("x")), Error.isError(new RangeError("x")), Error.isError(caught),
|
||||
Error.isError({ name: "Error", message: "x" }), Error.isError("Error"), Error.isError(null)]
|
||||
`),
|
||||
).toEqual([true, true, true, false, false, false])
|
||||
})
|
||||
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
|
||||
@@ -898,6 +898,42 @@ describe("Map", () => {
|
||||
).toEqual({ a: 3, b: 1, c: 1 })
|
||||
})
|
||||
|
||||
test("getOrInsert and getOrInsertComputed insert only when the key is missing", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const groups = new Map()
|
||||
groups.getOrInsert("a", []).push(1)
|
||||
groups.getOrInsert("a", []).push(2)
|
||||
let calls = 0
|
||||
const computed = (key) => { calls++; return key + "!" }
|
||||
const first = groups.getOrInsertComputed("b", computed)
|
||||
const second = groups.getOrInsertComputed("b", computed)
|
||||
const zero = groups.getOrInsertComputed(-0, (key) => 1 / key === Infinity)
|
||||
return [[...groups], first, second, calls, zero]
|
||||
`),
|
||||
).toEqual([
|
||||
[
|
||||
["a", [1, 2]],
|
||||
["b", "b!"],
|
||||
[0, true],
|
||||
],
|
||||
"b!",
|
||||
"b!",
|
||||
1,
|
||||
true,
|
||||
])
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map()
|
||||
const outer = m.getOrInsertComputed("k", () => { m.set("k", "inner"); return "outer" })
|
||||
let thrown
|
||||
try { m.getOrInsertComputed("j", () => { throw new Error("boom") }) } catch (error) { thrown = error.message }
|
||||
return [outer, m.get("k"), thrown, m.has("j")]
|
||||
`),
|
||||
).toEqual(["outer", "outer", "boom", false])
|
||||
expect((await error(`new Map().getOrInsertComputed("k", 5)`)).message).toContain("expects a function callback")
|
||||
})
|
||||
|
||||
test("maps serialize to {} at the boundary, like JSON", async () => {
|
||||
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
|
||||
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
|
||||
@@ -1061,7 +1097,7 @@ describe("Uint8Array", () => {
|
||||
console.log(b, new Uint8Array())
|
||||
return [String(b), b + "", +new Uint8Array([5]), Number.isNaN(Number(b)), b == "1,2", JSON.stringify(b), b.toLocaleString(), typeof b, b instanceof Uint8Array]
|
||||
`),
|
||||
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "[object Uint8Array]", "object", true])
|
||||
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "1,2", "object", true])
|
||||
expect((await run(`console.log(new Uint8Array([1, 2]), new Uint8Array())`)).logs).toEqual([
|
||||
"Uint8Array(2) [1,2] Uint8Array(0) []",
|
||||
])
|
||||
@@ -1231,6 +1267,49 @@ describe("built-in iterators", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("toLocaleString", () => {
|
||||
test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
(1234567.891).toLocaleString(), new Date(0).toLocaleString(), new Date(0).toLocaleDateString(),
|
||||
new Date(0).toLocaleTimeString(), "a".toLocaleString(), true.toLocaleString(), ({}).toLocaleString(),
|
||||
({ toString: () => "custom" }).toLocaleString(), new Uint8Array([1, 2]).toLocaleString(),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"1,234,567.891",
|
||||
"1/1/1970, 12:00:00 AM",
|
||||
"1/1/1970",
|
||||
"12:00:00 AM",
|
||||
"a",
|
||||
"true",
|
||||
"[object Object]",
|
||||
"custom",
|
||||
"1,2",
|
||||
])
|
||||
})
|
||||
|
||||
test("arrays join each element's toLocaleString, skipping holes and nullish elements", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let calls = 0
|
||||
const item = { toLocaleString() { calls++; return "o" } }
|
||||
return [[1234.5, "x", null, undefined, item, new Date(0)].toLocaleString(), [, item, , item].toLocaleString(), calls]
|
||||
`),
|
||||
).toEqual(["1,234.5,x,,,o,1/1/1970, 12:00:00 AM", ",o,,o", 3])
|
||||
expect((await error(`const f = ({}).toLocaleString; f()`)).message).toContain(
|
||||
"Object.prototype.toLocaleString called on null or undefined",
|
||||
)
|
||||
})
|
||||
|
||||
test("toLocaleLowerCase and toLocaleUpperCase ignore the locale argument", async () => {
|
||||
expect(
|
||||
await value(`return ["ABC".toLocaleLowerCase("tr"), "abc".toLocaleUpperCase(), "İ".toLocaleLowerCase()]`),
|
||||
).toEqual(["abc", "ABC", "i̇"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdlib integration", () => {
|
||||
test("constructor follows own keys, shadowing, writes, and new", async () => {
|
||||
expect(
|
||||
|
||||
@@ -225,7 +225,6 @@ built-ins/Array/prototype/splice/S15.4.4.12_A4_T1.js # Array.prototype.splice c
|
||||
built-ins/Array/prototype/splice/S15.4.4.12_A4_T2.js # Array.prototype.splice called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/splice/S15.4.4.12_A4_T3.js # Array.prototype.splice called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/splice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A1_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; var arr = [undefined, obj, null, obj, o
|
||||
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A3_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; Array.prototype[1] = obj; x = [obj]; x.
|
||||
built-ins/Array/prototype/toReversed/holes-not-preserved.js # Actual [4, undefined, 2, undefined, 0] and expected [4, 3, 2, undefined, 0] should have the same con
|
||||
built-ins/Array/prototype/toSorted/holes-not-preserved.js # Actual [1, 3, 4, undefined, undefined] and expected [1, 2, 3, 4, undefined] should have the same con
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -27,6 +27,7 @@ export function resolve(model: Model.Info, supports: readonly Support[] = [{ typ
|
||||
const EFFORTS = ["low", "medium", "high"]
|
||||
const ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
|
||||
const ANTHROPIC_OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay })
|
||||
|
||||
@@ -39,8 +40,9 @@ function budgets(
|
||||
model: Model.Info,
|
||||
support: Extract<Support, { type: "budget_tokens" }>,
|
||||
spell: (tokens: number) => Overlay,
|
||||
ceiling = model.limit.output,
|
||||
): Variants {
|
||||
const maximum = Math.min(support.max ?? model.limit.output - 1, model.limit.output - 1)
|
||||
const maximum = Math.min(support.max ?? ceiling - 1, model.limit.output - 1, ceiling - 1)
|
||||
if (maximum <= 0) return []
|
||||
const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
|
||||
return [variant("high", spell(high)), variant("max", spell(maximum))]
|
||||
@@ -273,9 +275,12 @@ const anthropicMessages: Protocol = (model, support) => {
|
||||
return toggle({ settings: { thinking: { type: "disabled" } } }, thinking)
|
||||
}
|
||||
case "budget_tokens":
|
||||
return budgets(model, support, (tokens) => ({
|
||||
settings: { thinking: { type: "enabled", budgetTokens: tokens } },
|
||||
}))
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => ({ settings: { thinking: { type: "enabled", budgetTokens: tokens } } }),
|
||||
ANTHROPIC_OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,10 +386,14 @@ const bedrockConverse: Protocol = (model, support) => {
|
||||
? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING }))
|
||||
: toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } }))
|
||||
case "budget_tokens":
|
||||
return budgets(model, support, (tokens) =>
|
||||
claude
|
||||
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
|
||||
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) =>
|
||||
claude
|
||||
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
|
||||
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
|
||||
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -436,9 +445,12 @@ const bedrockAISDK: Protocol = (model, support) => {
|
||||
{ settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } },
|
||||
)
|
||||
case "budget_tokens":
|
||||
return budgets(model, support, (tokens) => ({
|
||||
settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } },
|
||||
}))
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } } }),
|
||||
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,8 +501,11 @@ const sapAICore: Protocol = (model, support) => {
|
||||
return []
|
||||
case "budget_tokens":
|
||||
if (id.includes("anthropic"))
|
||||
return budgets(model, support, (tokens) =>
|
||||
sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
|
||||
ANTHROPIC_OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
if (id.includes("gemini"))
|
||||
return budgets(model, support, (tokens) =>
|
||||
|
||||
@@ -1018,11 +1018,11 @@ describe("ModelsDevPlugin", () => {
|
||||
const budgetModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-budget"))
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: Model.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 32000 } },
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
|
||||
})
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: Model.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 63999 } },
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
|
||||
})
|
||||
|
||||
const anthropicEffortModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7"))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode/core/plugin/promise"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.live("Promise tool executors receive interruption through their AbortSignal", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const started = yield* Deferred.make<AbortSignal>()
|
||||
yield* PluginPromise.fromPromise({
|
||||
id: "cancel-tool",
|
||||
async setup(context) {
|
||||
await context.tool.transform((editor) =>
|
||||
editor.add({
|
||||
name: "wait",
|
||||
description: "Wait until cancelled",
|
||||
input: { type: "object", properties: {}, additionalProperties: false },
|
||||
options: { codemode: false },
|
||||
execute: (_input, context) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
context.signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
|
||||
Effect.runSync(Deferred.succeed(started, context.signal))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}).effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const snapshot = yield* tools.snapshot()
|
||||
const fiber = yield* snapshot
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_promise_tool_cancel"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_tool_cancel"),
|
||||
call: { type: "tool-call", id: "call_promise_tool_cancel", name: "wait", input: {} },
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
const signal = yield* Deferred.await(started)
|
||||
expect(signal.aborted).toBe(false)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -93,6 +93,15 @@ test("recognizes Claude version spellings and future models", () => {
|
||||
settings: { effort, thinking: { type: "adaptive", display: "summarized" } },
|
||||
})),
|
||||
)
|
||||
|
||||
expect(
|
||||
resolve(model("@opencode/ai/providers/anthropic", "claude-haiku-4-5", 64_000), [
|
||||
{ type: "budget_tokens", min: 1_024, max: 64_000 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16_000 } } },
|
||||
{ id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31_999 } } },
|
||||
])
|
||||
})
|
||||
|
||||
test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
|
||||
|
||||
@@ -28,6 +28,29 @@ const sentry =
|
||||
})
|
||||
: false
|
||||
|
||||
// Every module the entry reaches through static imports lands in one chunk. Automatic splitting
|
||||
// otherwise fragments the initial graph into ~50 files shared with lazy routes, and each file costs
|
||||
// the renderer a main-thread request round trip through the main process before first paint.
|
||||
type ChunkingContext = { getModuleInfo(id: string): { isEntry: boolean; importers: readonly string[] } | null }
|
||||
const initialGraph = new WeakMap<ChunkingContext, Map<string, boolean>>()
|
||||
function inInitialGraph(id: string, ctx: ChunkingContext) {
|
||||
const memo = initialGraph.get(ctx) ?? new Map<string, boolean>()
|
||||
initialGraph.set(ctx, memo)
|
||||
const visit = (id: string, path: Set<string>): boolean => {
|
||||
const known = memo.get(id)
|
||||
if (known !== undefined) return known
|
||||
if (path.has(id)) return false
|
||||
const info = ctx.getModuleInfo(id)
|
||||
if (!info) return false
|
||||
path.add(id)
|
||||
const result = info.isEntry || info.importers.some((importer) => visit(importer, path))
|
||||
path.delete(id)
|
||||
memo.set(id, result)
|
||||
return result
|
||||
}
|
||||
return visit(id, new Set())
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
main: {
|
||||
resolve: {
|
||||
@@ -110,6 +133,11 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
input: {
|
||||
main: "src/renderer/index.html",
|
||||
},
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [{ name: (id, ctx) => (inInitialGraph(id, ctx) ? "app" : null), priority: 10 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -191,8 +191,14 @@ const phaseOrder = [
|
||||
["electron js init → entry", "nodeBootstrapped", "entryStart"],
|
||||
["entry → chromium ready", "entryStart", "electronReady"],
|
||||
["ready → window shown", "electronReady", "windowVisible"],
|
||||
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
|
||||
["layers → first log line", "bundleEvaluated", "appStarting"],
|
||||
["window → renderer assets served", "windowVisible", "rendererAssetsServed"],
|
||||
["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"],
|
||||
["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"],
|
||||
["onboarding → logging ready", "onboardingDecided", "loggingReady"],
|
||||
["logging → first log line", "loggingReady", "appStarting"],
|
||||
["first log line → storage open", "appStarting", "storageOpen"],
|
||||
["storage → initialization done", "storageOpen", "initializationDone"],
|
||||
["initialization → layers ready", "initializationDone", "layersReady"],
|
||||
["layers → renderer process", "appStarting", "rendererProcess"],
|
||||
["renderer boot → first paint", "rendererProcess", "firstPaint"],
|
||||
["first paint → shell", "firstPaint", "shellVisible"],
|
||||
@@ -397,8 +403,15 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
|
||||
entryStart: main.marks.entry && main.marks.entry - spawnAt,
|
||||
electronReady: main.marks.ready && main.marks.ready - spawnAt,
|
||||
rendererAssetsServed: main.marks.served && main.marks.served - spawnAt,
|
||||
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
|
||||
onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt,
|
||||
loggingReady: main.marks.logging && main.marks.logging - spawnAt,
|
||||
crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt,
|
||||
appStarting: main.appStarting && main.appStarting - spawnAt,
|
||||
storageOpen: main.marks.storage && main.marks.storage - spawnAt,
|
||||
initializationDone: main.marks.init && main.marks.init - spawnAt,
|
||||
layersReady: main.marks.layers && main.marks.layers - spawnAt,
|
||||
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
|
||||
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
|
||||
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
|
||||
@@ -593,8 +606,8 @@ function mainLog() {
|
||||
// A window shown before the logger existed reports when it was shown; the line itself is later.
|
||||
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
|
||||
if (shown) windowShownAt = Number(shown)
|
||||
if (/app starting/.test(message))
|
||||
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
|
||||
if (/app starting|layers ready/.test(message))
|
||||
for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value)
|
||||
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
param([Parameter(Mandatory)][string]$Name, [int]$Runs = 5)
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location $PSScriptRoot\..
|
||||
# electron-vite directly: the package's prebuild hook re-downloads the CLI, which the bench keeps fixed.
|
||||
bunx electron-vite build 2>&1 | Select-String -Pattern "built in|error" | Select-Object -Last 3
|
||||
if (-not (Test-Path out\main\index.js)) { throw "build failed" }
|
||||
bunx electron-builder --win --dir --config electron-builder.config.ts 2>&1 | Select-String -Pattern "error|signing with signtool.*OpenCode Dev" | Select-Object -Last 2
|
||||
if (Test-Path "dist\$Name-unpacked") { Remove-Item "dist\$Name-unpacked" -Recurse -Force }
|
||||
Rename-Item -Path dist\win-unpacked -NewName "$Name-unpacked"
|
||||
bun ./scripts/bench-startup.ts --exe "dist\base-unpacked\OpenCode Dev.exe" --compare "dist\$Name-unpacked\OpenCode Dev.exe" --runs $Runs --warmup 1 --window-at=-1700,20 --out "dist\bench-startup\$Name" 2>&1 | Select-String -Pattern "^warm service|^\s{2,}|^phases|^\S+\s+\d+\s+\(|^report|^warm-up|Error|error" | Select-Object -Last 45
|
||||
@@ -0,0 +1,94 @@
|
||||
// Attribute a renderer .cpuprofile's self time to original source files through the build's
|
||||
// source maps. Run with: bun scripts/profile-by-source.ts <profile.cpuprofile> [out/renderer/assets]
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { TraceMap, originalPositionFor } from "C:/Users/Lukem/.local/share/opencode/worktree/6c1049/quiet-wolf-2/node_modules/.bun/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
|
||||
|
||||
const profilePath = process.argv[2]!
|
||||
const assets = process.argv[3] ?? "out/renderer/assets"
|
||||
const profile = JSON.parse(readFileSync(profilePath, "utf8"))
|
||||
const maps = new Map<string, TraceMap>()
|
||||
for (const name of readdirSync(assets).filter((f) => f.endsWith(".js.map"))) {
|
||||
maps.set(name.slice(0, -4), new TraceMap(JSON.parse(readFileSync(join(assets, name), "utf8"))))
|
||||
}
|
||||
|
||||
const nodes = new Map<number, any>()
|
||||
for (const n of profile.nodes) nodes.set(n.id, n)
|
||||
const self = new Map<string, number>()
|
||||
const byPkg = new Map<string, number>()
|
||||
const group = (source: string) => {
|
||||
const n = source.replace(/\\/g, "/")
|
||||
const nm = n.match(/node_modules\/(?:\.bun\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)(?:\/dist\/([^/]+))?/)
|
||||
if (nm) return nm[1] === "effect" ? `effect/${(nm[2] ?? "").replace(/\.js$/, "")}` : nm[1]
|
||||
const pk = n.match(/packages\/([^/]+)\/src\/(.+)$/)
|
||||
return pk ? `${pk[1]}/${pk[2]}` : n.slice(-50)
|
||||
}
|
||||
const parent = new Map<number, number>()
|
||||
for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id)
|
||||
const resolve = (frame: any) => {
|
||||
const name = frame.functionName
|
||||
if (name === "(program)" || name === "(garbage collector)") return { label: name, fn: name }
|
||||
const file = frame.url.split("/").pop()
|
||||
const map = maps.get(file)
|
||||
if (!map) return { label: `(no map) ${file}`, fn: name }
|
||||
const pos = originalPositionFor(map, { line: frame.lineNumber + 1, column: frame.columnNumber })
|
||||
return { label: pos.source ? group(pos.source) : `(unmapped) ${file}`, fn: pos.name ?? name }
|
||||
}
|
||||
const labelOf = (frame: any) => resolve(frame).label
|
||||
// A sample belongs to the render phase once Solid's root is on the stack; everything before that is
|
||||
// module evaluation, everything after the first render is later work (hydration, effects, timers).
|
||||
const stackHas = (id: number, test: (label: string, fn: string) => boolean) => {
|
||||
for (let cur: number | undefined = id; cur !== undefined; cur = parent.get(cur)) {
|
||||
const resolved = resolve(nodes.get(cur).callFrame)
|
||||
if (test(resolved.label, resolved.fn)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const phases = { evaluate: new Map<string, number>(), render: new Map<string, number>(), later: new Map<string, number>() }
|
||||
let phase: keyof typeof phases = "evaluate"
|
||||
let t = 0
|
||||
let total = 0
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const dt = (profile.timeDeltas[i] ?? 0) / 1000
|
||||
t += dt
|
||||
const node = nodes.get(profile.samples[i])
|
||||
if (node.callFrame.functionName === "(idle)") {
|
||||
if (phase === "render" && dt > 5) phase = "later"
|
||||
continue
|
||||
}
|
||||
total += dt
|
||||
if (phase === "evaluate" && stackHas(node.id, (label, fn) => label === "solid-js" && (fn === "render" || fn === "createRoot")))
|
||||
phase = "render"
|
||||
const label = labelOf(node.callFrame)
|
||||
const bucket = phases[phase]
|
||||
bucket.set(label, (bucket.get(label) ?? 0) + dt)
|
||||
self.set(label, (self.get(label) ?? 0) + dt)
|
||||
const pkg = label.split("/").slice(0, label.startsWith("effect/") || label.startsWith("@") ? 2 : 1).join("/")
|
||||
byPkg.set(pkg, (byPkg.get(pkg) ?? 0) + dt)
|
||||
}
|
||||
console.log(`busy ${total.toFixed(0)} ms over ${t.toFixed(0)} ms`)
|
||||
void phases
|
||||
// Timeline: 25 ms buckets with the top sources, so module evaluation, render and hydration show as bands.
|
||||
const buckets = new Map<number, Map<string, number>>()
|
||||
t = 0
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const dt = (profile.timeDeltas[i] ?? 0) / 1000
|
||||
t += dt
|
||||
const node = nodes.get(profile.samples[i])
|
||||
if (node.callFrame.functionName === "(idle)") continue
|
||||
const b = Math.floor(t / 25) * 25
|
||||
const m = buckets.get(b) ?? new Map()
|
||||
const label = labelOf(node.callFrame).replace(/^(\.\.\/)+/, "")
|
||||
m.set(label, (m.get(label) ?? 0) + dt)
|
||||
buckets.set(b, m)
|
||||
}
|
||||
console.log("\n== timeline (25 ms buckets) ==")
|
||||
for (const [b, m] of [...buckets].sort((a, c) => a[0] - c[0])) {
|
||||
const busy = [...m.values()].reduce((a, c) => a + c, 0)
|
||||
if (busy < 1) continue
|
||||
const top = [...m].sort((a, c) => c[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" | ")
|
||||
console.log(String(b).padStart(5), busy.toFixed(0).padStart(3), top)
|
||||
}
|
||||
console.log("\n== by package (all) ==")
|
||||
for (const [k, v] of [...byPkg].sort((a, b) => b[1] - a[1]).slice(0, 20)) console.log(v.toFixed(1).padStart(7), k)
|
||||
|
||||
@@ -103,11 +103,17 @@ export function createBrowserPage(
|
||||
revision++
|
||||
})
|
||||
let closed = false
|
||||
// Whether the native surface holds a real document worth showing. Chromium keeps the
|
||||
// previous document painted until the next one renders, so a shown page stays shown
|
||||
// through later navigations; blank and failed documents hide until a real one is ready.
|
||||
let content = false
|
||||
let failure: { url: string; message: string } | undefined
|
||||
const state = (): Browser.Tab => ({
|
||||
id: options.id,
|
||||
url: contents.getURL().slice(0, 16_384),
|
||||
url: (failure?.url ?? contents.getURL()).slice(0, 16_384),
|
||||
title: contents.getTitle().slice(0, 2_048),
|
||||
loading: contents.isLoading(),
|
||||
...(failure ? { loadError: failure.message } : {}),
|
||||
canGoBack: contents.navigationHistory.canGoBack(),
|
||||
canGoForward: contents.navigationHistory.canGoForward(),
|
||||
generation,
|
||||
@@ -115,16 +121,40 @@ export function createBrowserPage(
|
||||
const publish = () => {
|
||||
if (!closed) options.publish()
|
||||
}
|
||||
const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
const reset = (event: Electron.Event<{ url: string; isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
if (!event.isMainFrame || event.isSameDocument) return
|
||||
failure = undefined
|
||||
generation++
|
||||
documents.clear()
|
||||
refs.clear()
|
||||
diagnostics.clear()
|
||||
publish()
|
||||
}
|
||||
const settle = () => {
|
||||
content = contents.getURL() !== "about:blank" && !failure
|
||||
updateVisibility()
|
||||
}
|
||||
contents.on("did-start-navigation", reset)
|
||||
contents.on("did-stop-loading", publish)
|
||||
contents.on("did-navigate", (_event, url, status, statusText) => {
|
||||
// The server-network proxy answers an unreachable HTTP target with an empty 502. Other
|
||||
// error statuses are real documents from the user's server and stay visible.
|
||||
if (status === 502) failure = { url, message: `${status} ${statusText}`.trim().slice(0, 2_048) }
|
||||
// A blank or failed document paints at commit; a real one waits for dom-ready.
|
||||
if (url === "about:blank" || failure) settle()
|
||||
publish()
|
||||
})
|
||||
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
|
||||
// Cancelled navigation and failed subframes do not replace the current page.
|
||||
if (!isMainFrame || code === -3) return
|
||||
failure = { url, message: description.slice(0, 2_048) }
|
||||
settle()
|
||||
publish()
|
||||
})
|
||||
contents.on("dom-ready", settle)
|
||||
contents.on("did-stop-loading", () => {
|
||||
settle()
|
||||
publish()
|
||||
})
|
||||
contents.on("did-navigate-in-page", publish)
|
||||
contents.on("page-title-updated", publish)
|
||||
contents.on("render-process-gone", () => {
|
||||
@@ -252,6 +282,14 @@ export function createBrowserPage(
|
||||
corner.setVisible(false)
|
||||
win.contentView.addChildView(corner)
|
||||
})
|
||||
let visible = false
|
||||
const updateVisibility = () => {
|
||||
// The renderer's layout requests may lag behind navigation; the page decides
|
||||
// whether there is a document worth exposing over the themed background.
|
||||
const show = visible && content
|
||||
view.setVisible(show)
|
||||
corners.forEach((corner) => corner.setVisible(show && !!cornerKey))
|
||||
}
|
||||
const ready = Promise.all([
|
||||
files.ready,
|
||||
...(options.initialize === false
|
||||
@@ -302,9 +340,9 @@ export function createBrowserPage(
|
||||
)
|
||||
})
|
||||
},
|
||||
setVisible(visible: boolean) {
|
||||
view.setVisible(visible)
|
||||
corners.forEach((corner) => corner.setVisible(visible && !!cornerKey))
|
||||
setVisible(value: boolean) {
|
||||
visible = value
|
||||
updateVisibility()
|
||||
},
|
||||
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
await ready
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Ipc } from "./ipc"
|
||||
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
|
||||
import { installContextMenu } from "./lifecycle/environment"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { DesktopLogging } from "./native/logging"
|
||||
import { BackgroundService } from "./service/background-service"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { UpdaterLive } from "./updater/live"
|
||||
@@ -15,8 +16,15 @@ marks.bundle = Date.now()
|
||||
|
||||
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
marks.layers = Date.now()
|
||||
yield* Effect.logInfo("layers ready", { marks })
|
||||
const ipc = yield* Ipc.registerIpcHandlers
|
||||
if (lifecycle.restoreWindows().length) ipc.installMenu()
|
||||
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
|
||||
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
|
||||
yield* Effect.sleep("500 millis")
|
||||
const logging = yield* DesktopLogging.Service
|
||||
yield* logging.startCrashReporter
|
||||
yield* installContextMenu
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
const quit = () => resume(Effect.void)
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { marks } from "./lifecycle/marks"
|
||||
import { app } from "electron"
|
||||
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
|
||||
import { startSidecarProbe } from "./service/sidecar-probe"
|
||||
import { registerStorageSnapshotHandler } from "./storage/snapshot"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
import { rendererAssetsServed } from "./windows/protocol"
|
||||
import { registerRendererScheme } from "./windows/scheme"
|
||||
|
||||
// This module stays small on purpose. Electron holds the ready event until the entry module has
|
||||
@@ -14,10 +17,16 @@ if (acquireApplicationLock()) {
|
||||
registerRendererScheme()
|
||||
// Window first, then the bundle: starting the import before ready delays ready itself, because the
|
||||
// module graph evaluates on the same thread Chromium needs to finish initialising.
|
||||
void app.whenReady().then(() => {
|
||||
void app.whenReady().then(async () => {
|
||||
marks.ready = Date.now()
|
||||
registerStorageSnapshotHandler()
|
||||
createEarlyWindow()
|
||||
marks.window = Date.now()
|
||||
startSidecarProbe()
|
||||
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
|
||||
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
|
||||
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
|
||||
marks.served = Date.now()
|
||||
return import("./desktop")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { isRendererUrl } from "../windows/protocol"
|
||||
import { isRendererUrl } from "../windows/scheme"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { sender } from "./context"
|
||||
|
||||
|
||||
@@ -65,12 +65,15 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
const post = () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
})
|
||||
}
|
||||
win.webContents.on("did-finish-load", post)
|
||||
// The first window starts loading before the layers exist and may already be done.
|
||||
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
app.on("browser-window-created", wire)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { enableCompileCache } from "node:module"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
@@ -31,6 +32,8 @@ export function configureApplication() {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
// V8 bytecode for the main bundle survives between launches, like the renderer's code cache.
|
||||
enableCompileCache(path.join(app.getPath("userData"), "compile-cache"))
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { app } from "electron"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { DesktopLogging } from "../native/logging"
|
||||
import { getStore } from "../storage/store"
|
||||
import { marks } from "./marks"
|
||||
import {
|
||||
loadProxyEnvironment,
|
||||
preferApplicationEnvironment,
|
||||
@@ -22,12 +23,15 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const logging = yield* DesktopLogging.Service
|
||||
yield* prepareApplicationEnvironment
|
||||
yield* preferApplicationEnvironment
|
||||
yield* loadProxyEnvironment
|
||||
// System certificates, the proxy and the net log serve later network work; the first window and
|
||||
// its IPC port do not wait for them.
|
||||
yield* Effect.forkScoped(
|
||||
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
|
||||
)
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
yield* logging.startNetwork
|
||||
yield* prepareDesktop
|
||||
marks.init = Date.now()
|
||||
return Service.of({
|
||||
version: app.getVersion(),
|
||||
updaterStore: getStore("opencode.updater"),
|
||||
|
||||
@@ -4,7 +4,8 @@ import { app } from "electron"
|
||||
import { Effect, Path } from "effect"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows"
|
||||
import { scoped } from "../native/logging"
|
||||
|
||||
// electron-context-menu attaches to every existing and future window, so it can load once the first
|
||||
// window is up instead of holding up startup with its dependency tree.
|
||||
@@ -37,7 +38,11 @@ export const prepareDesktop = Effect.gen(function* () {
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
yield* registerRendererProtocol()
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
setProtocolReporter((level, message, data) =>
|
||||
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
|
||||
)
|
||||
registerRendererProtocol(paths.rendererRoot)
|
||||
setDockIcon(path, paths)
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { marks } from "./marks"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
import { Shutdown } from "./shutdown"
|
||||
|
||||
@@ -157,6 +158,7 @@ export const layer = Layer.unwrap(
|
||||
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
|
||||
// otherwise read as evidence of an earlier launch on a fresh install.
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
marks.onboarding = Date.now()
|
||||
return runtime.pipe(Layer.provideMerge(platform))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
|
||||
// layer reports them with "app starting" so the startup benchmark can split the time before the
|
||||
// first log line into Electron's own initialisation, our entry, and the main bundle.
|
||||
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
|
||||
// layer reports the early ones with "app starting" and the rest with "layers ready", so the startup
|
||||
// benchmark can split the time before the renderer gets its IPC port into Electron's own
|
||||
// initialisation, our entry, the main bundle and each layer.
|
||||
export const marks: { entry: number } & Partial<
|
||||
Record<"ready" | "window" | "served" | "bundle" | "onboarding" | "logging" | "crash" | "storage" | "init" | "layers", number>
|
||||
> = { entry: Date.now() }
|
||||
|
||||
@@ -8,6 +8,10 @@ import { getStore } from "../storage/store"
|
||||
const DEFAULT_PROJECT_DIR = "Default Project"
|
||||
|
||||
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
|
||||
const store = getStore()
|
||||
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
|
||||
if (typeof current === "boolean") return current
|
||||
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
|
||||
@@ -17,11 +21,8 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
|
||||
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
|
||||
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const store = getStore()
|
||||
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
|
||||
if (typeof current === "boolean") return current
|
||||
|
||||
const complete = hasExistingAppState(entries)
|
||||
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
|
||||
return complete
|
||||
|
||||
@@ -19,6 +19,7 @@ let netLogPath: string | undefined
|
||||
|
||||
export interface Interface {
|
||||
readonly startNetwork: Effect.Effect<void>
|
||||
readonly startCrashReporter: Effect.Effect<void>
|
||||
readonly exportDebug: Effect.Effect<string>
|
||||
}
|
||||
|
||||
@@ -30,7 +31,9 @@ const serviceLayer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
yield* initLogging(fs, path).pipe(Effect.orDie)
|
||||
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
|
||||
// Old run directories go away in the background; listing them is not worth a wait at startup.
|
||||
yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void)))
|
||||
marks.logging = Date.now()
|
||||
yield* Effect.logInfo("app starting", {
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
@@ -42,6 +45,12 @@ const serviceLayer = Layer.effect(
|
||||
startNetwork: startNetLog(path).pipe(
|
||||
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
|
||||
),
|
||||
// Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window
|
||||
// and its IPC port come first.
|
||||
startCrashReporter: initCrashReporter(fs, path).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))),
|
||||
Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })),
|
||||
),
|
||||
exportDebug,
|
||||
})
|
||||
}),
|
||||
@@ -99,7 +108,6 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
log.initialize({ preload: false, spyRendererConsole: true })
|
||||
initConsoleTransport()
|
||||
})
|
||||
yield* cleanup(fs, path)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
import { cleanStages, DesktopCli } from "./desktop-cli"
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
import { sidecarProbe } from "./sidecar-probe"
|
||||
|
||||
export * as BackgroundService from "./background-service"
|
||||
|
||||
@@ -36,7 +37,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
const version = mode === "initial" ? cli.version : undefined
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const client = yield* Effect.promise(() => import("@opencode/client/service"))
|
||||
const service = yield* Effect.tryPromise(() =>
|
||||
const ensure = () =>
|
||||
client.Service.ensure({
|
||||
file:
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
@@ -46,13 +47,18 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
onStart: (reason, previousVersion) =>
|
||||
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
|
||||
}),
|
||||
)
|
||||
})
|
||||
// A compatible service the entry module already found is adopted at once; ensure() still runs
|
||||
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
|
||||
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
|
||||
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
|
||||
const service = early ?? (yield* Effect.tryPromise(ensure))
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
const url = new URL(service.url)
|
||||
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
|
||||
yield* Effect.logInfo("v2 CLI background service ready", {
|
||||
version,
|
||||
probed: !!early,
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as DesktopCli from "./desktop-cli"
|
||||
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
|
||||
@@ -93,9 +94,15 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
|
||||
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const shipped = yield* fs
|
||||
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
|
||||
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
|
||||
// Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued
|
||||
// async read waits behind everything else the main thread is doing at that moment.
|
||||
const shipped = yield* Effect.sync(() => {
|
||||
try {
|
||||
return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
})
|
||||
if (shipped) {
|
||||
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
|
||||
return shipped
|
||||
@@ -147,7 +154,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
|
||||
const path = yield* Path.Path
|
||||
const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
|
||||
const destination = path.join(directory, executableName())
|
||||
if (yield* fs.exists(destination)) {
|
||||
if (existsSync(destination)) {
|
||||
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
|
||||
return destination
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
import type { Endpoint } from "@opencode/client/service"
|
||||
|
||||
// The main thread idles between showing the first window and evaluating the main bundle, waiting
|
||||
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
|
||||
// background service is already running, so the renderer's first data request is not the first
|
||||
// moment anyone asks. The probe only looks; a service that has to be started waits for the layers,
|
||||
// which set the environment the CLI expects.
|
||||
let probe: Promise<Endpoint | undefined> | undefined
|
||||
|
||||
export function startSidecarProbe() {
|
||||
if (!app.isPackaged) return
|
||||
const version = bundledVersion()
|
||||
if (!version) return
|
||||
probe = import("@opencode/client/service")
|
||||
.then(({ Service }) => Service.discover({ version }))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sidecarProbe() {
|
||||
return probe ?? Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
function bundledVersion() {
|
||||
try {
|
||||
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ export * as DesktopStorage from "./index"
|
||||
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { Context, Effect, Layer, Path } from "effect"
|
||||
import { marks } from "../lifecycle/marks"
|
||||
import { openDatabase } from "./database"
|
||||
import { setStorageSnapshotProvider } from "./snapshot"
|
||||
import { createDraftStore } from "./drafts"
|
||||
import { importLegacyStores } from "./legacy"
|
||||
import { createStateStore } from "./state"
|
||||
@@ -40,6 +42,8 @@ export const layer = Layer.effect(
|
||||
storage.close()
|
||||
}),
|
||||
)
|
||||
setStorageSnapshotProvider((names) => Object.fromEntries(names.map((name) => [name, storage.state.items(name)])))
|
||||
marks.storage = Date.now()
|
||||
return Service.of(storage)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { app, ipcMain } from "electron"
|
||||
import { StorageSnapshotChannel, type StorageSnapshot } from "../../shared/ipc-transport"
|
||||
import { isRendererUrl } from "../windows/scheme"
|
||||
|
||||
// A window's preload asks for the namespaces its shell reads before the page runs, so the first
|
||||
// render is the hydrated one. Until the storage layer is up (the first window asks before it exists)
|
||||
// the answer comes from the database file; the layer takes over so later windows see queued writes.
|
||||
type Provider = (names: ReadonlyArray<string>) => StorageSnapshot
|
||||
|
||||
let provider: Provider = readFromDisk
|
||||
|
||||
export function setStorageSnapshotProvider(next: Provider) {
|
||||
provider = next
|
||||
}
|
||||
|
||||
export function registerStorageSnapshotHandler() {
|
||||
ipcMain.handle(StorageSnapshotChannel, (event, names: unknown): StorageSnapshot => {
|
||||
if (!isRendererUrl(event.senderFrame?.url)) return {}
|
||||
if (!Array.isArray(names)) return {}
|
||||
return provider(names.filter((name): name is string => typeof name === "string"))
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing has been written in this process yet, so every namespace is at revision 0, as the
|
||||
// storage layer would report before its first update. Legacy electron-store files still waiting
|
||||
// to be imported would make the database stale for this launch, so the renderer asks the layer then.
|
||||
function readFromDisk(names: ReadonlyArray<string>): StorageSnapshot {
|
||||
const userData = app.getPath("userData")
|
||||
const file = path.join(userData, "drafts.sqlite")
|
||||
if (!existsSync(file)) return {}
|
||||
if (readdirSync(userData).some((name) => name === "default.dat" || /^opencode\..+\.dat$/.test(name))) return {}
|
||||
try {
|
||||
const db = new DatabaseSync(file)
|
||||
try {
|
||||
const rows = db.prepare("SELECT key, value FROM state WHERE name = ?")
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
{
|
||||
items: Object.fromEntries(
|
||||
(rows.all(name) as { key: string; value: string }[]).map((row) => [row.key, row.value]),
|
||||
),
|
||||
revision: 0,
|
||||
},
|
||||
]),
|
||||
)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { windowBootstrapArgument, type WindowBootstrap } from "../../shared/window-bootstrap"
|
||||
import { getDefaultServerUrl } from "../service/server-settings"
|
||||
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
// The settings store is already in memory when a window is created, so the renderer gets the
|
||||
// answers its shell gate would otherwise ask for over IPC. A fresh install has no onboarding
|
||||
// decision yet; the renderer asks once the layers have made one.
|
||||
export function windowBootstrap(id: string): WindowBootstrap {
|
||||
const complete = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
|
||||
return {
|
||||
id,
|
||||
firstLaunchPending: typeof complete === "boolean" ? !complete : undefined,
|
||||
defaultServerUrl: getDefaultServerUrl(),
|
||||
}
|
||||
}
|
||||
|
||||
export function windowArguments(id: string) {
|
||||
return [windowBootstrapArgument(windowBootstrap(id))]
|
||||
}
|
||||
@@ -9,10 +9,6 @@ import { getStore } from "../storage/store"
|
||||
// full window setup in appearance.ts, so both draw the same frame.
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
export const titlebarHeight = 44
|
||||
|
||||
@@ -21,10 +17,13 @@ export function tone() {
|
||||
}
|
||||
|
||||
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
|
||||
// a window shown before the renderer paints already has the right background.
|
||||
// a window shown before the renderer paints already has the right background. Resolving a palette
|
||||
// costs tens of milliseconds before the first window, so it only happens when nothing is stored.
|
||||
export function storedBackgroundColor() {
|
||||
const stored = getStore().get(BACKGROUND_COLOR_KEY)
|
||||
return typeof stored === "string" ? stored : oc2Background[tone()]
|
||||
if (typeof stored === "string") return stored
|
||||
const dark = tone() === "dark"
|
||||
return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"]
|
||||
}
|
||||
|
||||
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { app, BrowserWindow, screen, shell } from "electron"
|
||||
import { resolveExternalURL } from "../files/external-url"
|
||||
import { windowArguments } from "./bootstrap"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
|
||||
import { registerRendererProtocol } from "./protocol"
|
||||
import { loadWindow } from "./scheme"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
|
||||
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
|
||||
export type EarlyWindow = {
|
||||
id: string
|
||||
win: BrowserWindow
|
||||
state: WindowState
|
||||
shownAt: number
|
||||
// Navigation policy is wired before the layers exist; the adopter swaps in the logged version.
|
||||
openExternal: (url: string) => void
|
||||
}
|
||||
|
||||
let pending: EarlyWindow | undefined
|
||||
|
||||
@@ -46,7 +57,7 @@ export function createEarlyWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
additionalArguments: windowArguments(id),
|
||||
},
|
||||
})
|
||||
manageWindowState(win, file, state, displays)
|
||||
@@ -56,7 +67,24 @@ export function createEarlyWindow() {
|
||||
pending = undefined
|
||||
app.quit()
|
||||
})
|
||||
pending = { id, win, state, shownAt: Date.now() }
|
||||
const record: EarlyWindow = {
|
||||
id,
|
||||
win,
|
||||
state,
|
||||
shownAt: Date.now(),
|
||||
openExternal: (url) => {
|
||||
const target = resolveExternalURL(url)
|
||||
if (target) void shell.openExternal(target)
|
||||
},
|
||||
}
|
||||
pending = record
|
||||
// The renderer boots while the main bundle and layers load, instead of after them. Everything the
|
||||
// page needs before its first request is wired here; the IPC port arrives once the layers are up.
|
||||
registerRendererProtocol(path.join(root, "../renderer"))
|
||||
allowRendererPermissions(win)
|
||||
wireNavigationPolicy(win, (url) => record.openExternal(url))
|
||||
wireRendererHeaders(win)
|
||||
loadWindow(win, "index.html")
|
||||
}
|
||||
|
||||
export function takeEarlyWindow() {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { DesktopPaths } from "../paths"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { getStore } from "../storage/store"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { windowDataFile } from "../../shared/ipc-transport"
|
||||
import { windowArguments } from "./bootstrap"
|
||||
import {
|
||||
getBackgroundColor,
|
||||
getPinchZoomEnabled,
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
wireFullscreen,
|
||||
wireZoom,
|
||||
} from "./appearance"
|
||||
import { loadWindow, registerRendererProtocol } from "./protocol"
|
||||
import { registerRendererProtocol, setProtocolReporter } from "./protocol"
|
||||
import { loadWindow } from "./scheme"
|
||||
import { createWindowRegistry } from "./registry"
|
||||
import { makeWindowRecovery } from "./recovery"
|
||||
import { takeEarlyWindow, type EarlyWindow } from "./early"
|
||||
@@ -48,6 +50,7 @@ export {
|
||||
getBackgroundColor,
|
||||
getPinchZoomEnabled,
|
||||
registerRendererProtocol,
|
||||
setProtocolReporter,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
setPinchZoomEnabled,
|
||||
@@ -115,18 +118,23 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
additionalArguments: windowArguments(id),
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
// The early window was secured and loaded when it was created; only its external-URL policy is
|
||||
// upgraded to the logged one.
|
||||
if (early) early.openExternal = (url) => runFork(openExternalURL(url))
|
||||
if (!early) {
|
||||
allowRendererPermissions(win)
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
manageWindowState(win, stateFile, state, displays)
|
||||
}
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
if (!early) manageWindowState(win, stateFile, state, displays)
|
||||
register(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
if (!early) loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
@@ -182,12 +190,4 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
return { create, restore }
|
||||
})
|
||||
|
||||
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
|
||||
// namespace the renderer persists this window's tabs under.
|
||||
function windowDataFile(id: string) {
|
||||
return `opencode.window.${safeWindowID(id)}.dat`
|
||||
}
|
||||
|
||||
function safeWindowID(id: string) {
|
||||
return id.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
}
|
||||
|
||||
@@ -1,73 +1,85 @@
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
export type ProtocolReport = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
|
||||
|
||||
// The entry module registers the handler the moment the first window exists, before logging is up,
|
||||
// so problems go to the console until the logging layer installs a reporter.
|
||||
let report: ProtocolReport = (level, message, data) => console[level === "error" ? "error" : "warn"](message, data)
|
||||
|
||||
export function setProtocolReporter(reporter: ProtocolReport) {
|
||||
report = reporter
|
||||
}
|
||||
|
||||
// Requests in flight and when the last one arrived. The entry module holds the main bundle back
|
||||
// until the renderer's initial burst of asset requests has been answered, because this handler
|
||||
// runs on the main thread and a 100 ms module evaluation would otherwise sit between the renderer
|
||||
// and its HTML.
|
||||
let inflight = 0
|
||||
let served = 0
|
||||
let lastRequest = 0
|
||||
|
||||
export function rendererAssetsServed(options: { quietMs: number; capMs: number }) {
|
||||
const start = Date.now()
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
const now = Date.now()
|
||||
if (now - start >= options.capMs) return resolve()
|
||||
if (served > 0 && inflight === 0 && now - lastRequest >= options.quietMs) return resolve()
|
||||
setTimeout(check, 5)
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
export function registerRendererProtocol(rendererRoot: string) {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(paths.rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
inflight++
|
||||
lastRequest = Date.now()
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
runFork(
|
||||
scoped(
|
||||
"protocol",
|
||||
Effect.logError("fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
return await serve(request, rendererRoot)
|
||||
} finally {
|
||||
inflight--
|
||||
served++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(new URL(html, devUrl).toString())
|
||||
return
|
||||
}
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
export function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
async function serve(request: Request, rendererRoot: string) {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
report("warning", "rejected host", { url: request.url })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
report("warning", "rejected path", { url: request.url, file })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
report("error", "fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
report("error", "fetch error", { url: request.url, file, error })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
|
||||
export const rendererProtocol = "oc"
|
||||
export const rendererHost = "renderer"
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(new URL(html, devUrl).toString())
|
||||
return
|
||||
}
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
export function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
|
||||
// before it loads anything else.
|
||||
export function registerRendererScheme() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
import { isRendererUrl } from "./scheme"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { windowIDFromArguments } from "../shared/window-bootstrap"
|
||||
import {
|
||||
DragCancelEvent,
|
||||
IpcTransportPort,
|
||||
StorageSnapshotChannel,
|
||||
storageSnapshotNames,
|
||||
type StorageSnapshot,
|
||||
} from "../shared/ipc-transport"
|
||||
import { windowBootstrapFromArguments } from "../shared/window-bootstrap"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
const port = event.ports[0]
|
||||
@@ -9,7 +15,15 @@ ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
|
||||
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
|
||||
|
||||
const bootstrap = windowBootstrapFromArguments(process.argv)
|
||||
// Asked before the page runs, so the stores the shell reads are hydrated on the first render.
|
||||
const storageSnapshot: Promise<StorageSnapshot> = ipcRenderer
|
||||
.invoke(StorageSnapshotChannel, storageSnapshotNames(bootstrap.id))
|
||||
.catch(() => ({}))
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
windowID: windowIDFromArguments(process.argv),
|
||||
windowID: bootstrap.id,
|
||||
bootstrap,
|
||||
storageSnapshot,
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { StorageSnapshot } from "../shared/ipc-transport"
|
||||
import type { WindowBootstrap } from "../shared/window-bootstrap"
|
||||
|
||||
export type ElectronNative = {
|
||||
windowID: string
|
||||
bootstrap: WindowBootstrap
|
||||
storageSnapshot: Promise<StorageSnapshot>
|
||||
getPathForFile(file: File): string
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { UpdaterState } from "@opencode/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode/app/wsl/types"
|
||||
import type { SshPlatform } from "@opencode/app/ssh"
|
||||
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
|
||||
import type { WindowBootstrap } from "../shared/window-bootstrap"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
@@ -53,6 +54,7 @@ export type ElectronAPI = {
|
||||
draftBlobPut(data: ArrayBuffer): Promise<string>
|
||||
draftBlobGet(id: string): Promise<ArrayBuffer | null>
|
||||
getWindowID(): string
|
||||
getWindowBootstrap(): WindowBootstrap
|
||||
themeReady(): Promise<void>
|
||||
onMenuCommand(cb: (id: string) => void): () => void
|
||||
onDeepLink(cb: (urls: string[]) => void): () => void
|
||||
|
||||
@@ -22,6 +22,9 @@ const updaterHandler = (state: UpdaterState) => {
|
||||
updaterCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
// One renderer-side copy: the bridge clones on every crossing, so consumption is tracked here.
|
||||
const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object.entries(snapshot)))
|
||||
|
||||
export const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
reconnectService: () => invoke("AppReconnectService"),
|
||||
@@ -99,7 +102,15 @@ export const api: ElectronAPI = {
|
||||
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
|
||||
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
|
||||
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
|
||||
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
|
||||
// The first read of a namespace the preload already fetched is served from that snapshot; later
|
||||
// reads (a window re-opening a namespace) go to the main process as usual.
|
||||
storeItems: (name) =>
|
||||
seeded.then((snapshot) => {
|
||||
const item = snapshot.get(name)
|
||||
if (!item) return invoke("StorageItems", { name }).then(mutable)
|
||||
snapshot.delete(name)
|
||||
return item
|
||||
}),
|
||||
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
|
||||
storeClear: (name) => invoke("StorageClear", { name }),
|
||||
onStoreChanged: (cb) =>
|
||||
@@ -111,6 +122,7 @@ export const api: ElectronAPI = {
|
||||
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
|
||||
|
||||
getWindowID: () => window.electron.windowID,
|
||||
getWindowBootstrap: () => window.electron.bootstrap,
|
||||
themeReady: () => invoke("WindowThemeReady"),
|
||||
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
|
||||
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
|
||||
|
||||
@@ -48,15 +48,23 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
drawingReady: false,
|
||||
route,
|
||||
})
|
||||
// The window was created with the answers the shell gate needs; only a fresh install, which has no
|
||||
// onboarding decision yet, asks over IPC and waits for the port.
|
||||
const bootstrap = props.api.getWindowBootstrap()
|
||||
const [firstLaunch] = createResource(() =>
|
||||
props.api.isFirstLaunchOnboardingPending().catch((error) => {
|
||||
console.error("[desktop-onboarding] first launch check failed", error)
|
||||
return false
|
||||
}),
|
||||
bootstrap.firstLaunchPending !== undefined
|
||||
? Promise.resolve(bootstrap.firstLaunchPending)
|
||||
: props.api.isFirstLaunchOnboardingPending().catch((error) => {
|
||||
console.error("[desktop-onboarding] first launch check failed", error)
|
||||
return false
|
||||
}),
|
||||
)
|
||||
const platform = createDesktopPlatform(props.api, windowState, props.updater)
|
||||
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [defaultServer] = createResource(async () => {
|
||||
if (bootstrap.defaultServerUrl === undefined) return platform.getDefaultServer?.()
|
||||
return bootstrap.defaultServerUrl ? ServerConnection.Key.make(bootstrap.defaultServerUrl) : null
|
||||
})
|
||||
const [locale] = createResource(() => preloadStoredLocale(platform))
|
||||
const [initialRoute] = createResource(
|
||||
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { Effect } from "effect"
|
||||
import type { RpcMessage } from "effect/unstable/rpc"
|
||||
import type { DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
|
||||
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
|
||||
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
|
||||
// directly. Messages cross by structured clone (no serialization layer, binary stays binary), every
|
||||
// payload in the contract is JSON-native or a Uint8Array, and the main process is trusted, so the
|
||||
// renderer needs neither the Effect runtime nor the contract's schemas to talk to it. Keeping them
|
||||
// out of the renderer's initial module graph is worth about a third of its startup script.
|
||||
|
||||
type EventTag = DesktopEvent["_tag"]
|
||||
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
|
||||
@@ -13,55 +17,48 @@ type InvokeResult<Tag extends InvokeTag> =
|
||||
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
|
||||
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
|
||||
|
||||
type Pending = {
|
||||
readonly resolve: (value: unknown) => void
|
||||
readonly reject: (error: unknown) => void
|
||||
readonly chunk?: (values: ReadonlyArray<unknown>) => void
|
||||
}
|
||||
|
||||
const pending = new Map<number, Pending>()
|
||||
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
|
||||
const beforeDispose = new Set<() => Promise<unknown> | void>()
|
||||
let nextId = 0
|
||||
|
||||
const port = new Promise<MessagePort>((resolve) => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window || event.data !== IpcTransportPort) return
|
||||
const value = event.ports[0]
|
||||
if (!value) return
|
||||
window.removeEventListener("message", onMessage)
|
||||
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
|
||||
value.start()
|
||||
resolve(value)
|
||||
}
|
||||
window.addEventListener("message", onMessage)
|
||||
})
|
||||
|
||||
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
|
||||
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
|
||||
const runtime = ManagedRuntime.make(ClientLive)
|
||||
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
|
||||
const beforeDispose = new Set<() => Promise<unknown> | void>()
|
||||
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
|
||||
// Let queued work (storage flushes) hand its messages to the port before it closes.
|
||||
window.addEventListener(
|
||||
"pagehide",
|
||||
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
|
||||
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => port.then((p) => p.close())),
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
void request("DesktopEvents", null, (values) => {
|
||||
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
|
||||
})
|
||||
|
||||
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
|
||||
beforeDispose.add(callback)
|
||||
return () => beforeDispose.delete(callback)
|
||||
}
|
||||
|
||||
runtime.runFork(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DesktopClient
|
||||
yield* client
|
||||
.DesktopEvents()
|
||||
.pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
|
||||
return runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DesktopClient
|
||||
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
|
||||
return yield* method(...payload)
|
||||
}),
|
||||
) as Promise<InvokeResult<Tag>>
|
||||
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
|
||||
}
|
||||
|
||||
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
|
||||
@@ -79,42 +76,49 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
|
||||
}
|
||||
}
|
||||
|
||||
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
|
||||
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
|
||||
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
|
||||
function clientProtocol(value: MessagePort) {
|
||||
return Layer.effect(
|
||||
RpcClient.Protocol,
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
|
||||
}
|
||||
value.addEventListener("message", onMessage)
|
||||
value.start()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
value.removeEventListener("message", onMessage)
|
||||
value.close()
|
||||
}),
|
||||
)
|
||||
yield* Stream.fromQueue(inbound).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
codecFor: Schema.toCodecJson,
|
||||
send: (_clientId, request) =>
|
||||
Effect.sync(() => {
|
||||
value.postMessage(request)
|
||||
}),
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) {
|
||||
const id = nextId++
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject, chunk })
|
||||
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
|
||||
void port.then((p) => p.postMessage(message))
|
||||
})
|
||||
}
|
||||
|
||||
function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
|
||||
switch (message._tag) {
|
||||
case "Chunk": {
|
||||
pending.get(Number(message.requestId))?.chunk?.(message.values)
|
||||
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
|
||||
return
|
||||
}
|
||||
case "Exit": {
|
||||
const id = Number(message.requestId)
|
||||
const entry = pending.get(id)
|
||||
pending.delete(id)
|
||||
if (!entry) return
|
||||
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
|
||||
return entry.reject(failure(message.exit.cause))
|
||||
}
|
||||
case "Defect": {
|
||||
const error = new Error("Desktop IPC defect", { cause: message.defect })
|
||||
pending.forEach((entry) => entry.reject(error))
|
||||
pending.clear()
|
||||
return
|
||||
}
|
||||
case "ClientProtocolError": {
|
||||
console.error("[desktop-ipc] protocol error", message.error)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The RPC failure a caller sees is the encoded error the handler failed with, as before; defects
|
||||
// and interrupts surface as errors.
|
||||
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
|
||||
const failed = cause.find((item) => item._tag === "Fail")
|
||||
if (failed) return failed.error
|
||||
const died = cause.find((item) => item._tag === "Die")
|
||||
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
|
||||
return new Error("Desktop IPC request interrupted")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode/app/desktop"
|
||||
import { storedLocaleValue } from "./locale-value"
|
||||
|
||||
// The stored language lives in the main process's SQLite store, behind the IPC port. A copy of the
|
||||
// last answer in localStorage lets the shell mount without waiting for the port; the store is still
|
||||
// asked every launch and the copy refreshed, and the language provider hydrates from the store
|
||||
// itself, so a stale copy costs one visible switch, not a wrong language.
|
||||
const cacheKey = "opencode.desktop.language"
|
||||
|
||||
export async function preloadStoredLocale(platform: Platform) {
|
||||
const raw = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const locale = storedLocale(raw)
|
||||
const fresh = Promise.resolve(platform.storage?.("opencode.global.dat").getItem("language")).then(
|
||||
(raw) => {
|
||||
localStorage.setItem(cacheKey, raw ?? "")
|
||||
return raw
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
const cached = localStorage.getItem(cacheKey)
|
||||
const locale = storedLocale(cached ?? (await fresh))
|
||||
if (!locale) return
|
||||
if (locale !== "en") await loadLocaleDict(locale)
|
||||
return locale
|
||||
|
||||
@@ -1,2 +1,16 @@
|
||||
export const IpcTransportPort = "desktop-rpc-port"
|
||||
export const DragCancelEvent = "opencode:drag-cancel"
|
||||
export const StorageSnapshotChannel = "desktop-storage-snapshot"
|
||||
|
||||
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
|
||||
|
||||
// The namespaces a window reads while its shell mounts. The preload asks for them before the page
|
||||
// runs so the first render already has them; mirrors windowStorage() in
|
||||
// packages/app/src/runtime/persistence/storage.ts.
|
||||
export function storageSnapshotNames(windowID: string) {
|
||||
return ["opencode.global.dat", "default.dat", windowDataFile(windowID)]
|
||||
}
|
||||
|
||||
export function windowDataFile(id: string) {
|
||||
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
|
||||
import { windowBootstrapArgument, windowBootstrapFromArguments } from "./window-bootstrap"
|
||||
|
||||
describe("window bootstrap", () => {
|
||||
test("round-trips the window ID through renderer arguments", () => {
|
||||
const id = "window/id with spaces"
|
||||
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
|
||||
test("round-trips through argv", () => {
|
||||
const bootstrap = { id: "win a/b ü", firstLaunchPending: false, defaultServerUrl: "http://127.0.0.1:1234" }
|
||||
expect(windowBootstrapFromArguments(["electron", windowBootstrapArgument(bootstrap)])).toEqual(bootstrap)
|
||||
})
|
||||
|
||||
test("requires a window ID argument", () => {
|
||||
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
|
||||
test("keeps unknown values absent", () => {
|
||||
expect(windowBootstrapFromArguments([windowBootstrapArgument({ id: "x" })])).toEqual({ id: "x" })
|
||||
})
|
||||
|
||||
test("throws when the argument is missing", () => {
|
||||
expect(() => windowBootstrapFromArguments(["electron"])).toThrow("Window bootstrap argument not found")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
const windowIDPrefix = "--opencode-window-id="
|
||||
|
||||
export function windowIDArgument(id: string) {
|
||||
return windowIDPrefix + encodeURIComponent(id)
|
||||
// What the main process already knows when it creates a window, handed to the renderer through the
|
||||
// preload's argv so the shell can mount before the IPC port exists. Undefined means "ask over IPC".
|
||||
export type WindowBootstrap = {
|
||||
id: string
|
||||
firstLaunchPending?: boolean
|
||||
defaultServerUrl?: string | null
|
||||
}
|
||||
|
||||
export function windowIDFromArguments(args: readonly string[]) {
|
||||
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
|
||||
if (!value) throw new Error("Window ID argument not found")
|
||||
return decodeURIComponent(value)
|
||||
const prefix = "--opencode-window="
|
||||
|
||||
export function windowBootstrapArgument(bootstrap: WindowBootstrap) {
|
||||
return prefix + encodeURIComponent(JSON.stringify(bootstrap))
|
||||
}
|
||||
|
||||
export function windowBootstrapFromArguments(args: readonly string[]): WindowBootstrap {
|
||||
const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
|
||||
if (!value) throw new Error("Window bootstrap argument not found")
|
||||
return JSON.parse(decodeURIComponent(value))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,25 @@ async function main() {
|
||||
app.setPath("userData", path.join(root, "electron-data"))
|
||||
app.on("window-all-closed", () => {})
|
||||
await app.whenReady()
|
||||
let unavailable = true
|
||||
let finishLoading: (() => void) | undefined
|
||||
const web = createServer((request, response) => {
|
||||
if (request.url === "/loading") {
|
||||
response.writeHead(200, { "content-type": "text/html" })
|
||||
response.write("<!doctype html><html><body>Loading")
|
||||
finishLoading = () => response.end("</body></html>")
|
||||
return
|
||||
}
|
||||
if (request.url === "/finish-loading") {
|
||||
finishLoading?.()
|
||||
finishLoading = undefined
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (request.url === "/unreachable" && unavailable) {
|
||||
request.socket.destroy()
|
||||
return
|
||||
}
|
||||
if (request.url === "/cors-allowed" || request.url === "/cors-denied") {
|
||||
if (request.url === "/cors-allowed") response.setHeader("access-control-allow-origin", "*")
|
||||
response.setHeader("content-type", "text/plain")
|
||||
@@ -204,6 +222,19 @@ async function main() {
|
||||
assert.equal((await call("tabs.list", {})).tabs.length, 2)
|
||||
const tabID = first.id
|
||||
assert.equal(first.url, fixture + "/")
|
||||
assert.equal((await call("navigate", { tabID, url: `${fixture}/unreachable` })).loadError, "502 Bad Gateway")
|
||||
const failed = await until(async () => {
|
||||
const tab = (await call("tabs.list", {})).tabs.find((tab) => tab.id === tabID)
|
||||
return tab?.loadError && !tab.loading ? tab : undefined
|
||||
})
|
||||
assert.equal(failed.url, `${fixture}/unreachable`)
|
||||
assert.equal(failed.loadError, "502 Bad Gateway")
|
||||
await until(async () => inventories.get("suite")?.tabs.find((tab) => tab.id === tabID)?.loadError)
|
||||
assert.equal((await call("tabs.list", {})).tabs.find((tab) => tab.id === second.id)?.loadError, undefined)
|
||||
unavailable = false
|
||||
assert.equal((await call("reload", { tabID })).loadError, undefined)
|
||||
assert.equal((await call("navigate", { tabID, url: fixture })).loadError, undefined)
|
||||
console.log("PASS browser navigation failure state and retry")
|
||||
const networkProof = await call("evaluate", {
|
||||
tabID,
|
||||
script: `(async () => ({
|
||||
|
||||
@@ -29,6 +29,58 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
}
|
||||
try {
|
||||
await page.ready
|
||||
const started = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
|
||||
page.setVisible(true)
|
||||
const navigation = execute({ type: "navigate", tabID, url: url + "/loading" })
|
||||
await started
|
||||
assert(page.state().loading)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
const stopped = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
|
||||
await fetch(url + "/finish-loading")
|
||||
await navigation
|
||||
await stopped
|
||||
assert.equal(page.state().loading, false)
|
||||
assert(page.view.getVisible(), "The requested visibility must return after loading finishes")
|
||||
console.log("PASS native loading from a blank page keeps the themed background visible until the document is ready")
|
||||
const again = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
|
||||
const reloading = execute({ type: "navigate", tabID, url: url + "/loading" })
|
||||
await again
|
||||
assert(page.state().loading)
|
||||
assert(page.view.getVisible(), "A shown page must stay visible while the next document loads")
|
||||
await fetch(url + "/finish-loading")
|
||||
await reloading
|
||||
assert(page.view.getVisible())
|
||||
console.log("PASS native loading from a shown page keeps the previous document visible")
|
||||
await execute({ type: "navigate", tabID, url: url + "/missing" })
|
||||
assert.equal(page.state().loadError, undefined, "An HTTP error page from the server is a real document")
|
||||
page.setVisible(true)
|
||||
assert(page.view.getVisible(), "An HTTP error page from the server must stay visible")
|
||||
console.log("PASS native HTTP error pages stay visible")
|
||||
await assert.rejects(execute({ type: "navigate", tabID, url: url + "/unreachable" }), /ERR_EMPTY_RESPONSE/)
|
||||
assert.equal(page.state().loadError, "ERR_EMPTY_RESPONSE")
|
||||
assert.equal(page.state().url, url + "/unreachable")
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
const clearing = new Promise<void>((resolve) => {
|
||||
page.contents.once("did-start-navigation", () => {
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await execute({ type: "navigate", tabID, url: "about:blank" })
|
||||
await clearing
|
||||
assert.equal(page.state().loadError, undefined)
|
||||
page.setVisible(true)
|
||||
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
|
||||
const restored = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
|
||||
await execute({ type: "navigate", tabID, url })
|
||||
await restored
|
||||
page.setVisible(true)
|
||||
assert(page.view.getVisible())
|
||||
console.log("PASS native blank navigation stays hidden through stale layout updates")
|
||||
for (const color of [
|
||||
[255, 255, 255, 255],
|
||||
[20, 24, 30, 255],
|
||||
@@ -91,6 +143,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
|
||||
|
||||
await execute({ type: "evaluate", tabID, script: "document.querySelector('a[href=\"/download\"]').click()" })
|
||||
const fileID = await waitForFile()
|
||||
assert.equal(page.state().loadError, undefined, "A download must not mark the page as unreachable")
|
||||
const fileAction: Browser.Action = { type: "files.get", tabID, fileID }
|
||||
const original = await inspect(fileAction)
|
||||
assert(original.resources.includes(url + "/download"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -54,6 +54,7 @@ export const Tab = Schema.Struct({
|
||||
url: Schema.String.check(Schema.isMaxLength(16_384)),
|
||||
title: short,
|
||||
loading: Schema.Boolean,
|
||||
loadError: optional(short),
|
||||
canGoBack: Schema.Boolean,
|
||||
canGoForward: Schema.Boolean,
|
||||
generation: count,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -237,25 +237,26 @@ export function fromPromise(plugin: Plugin) {
|
||||
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
|
||||
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
|
||||
const WorktreeEndpoints = ClientApi.groups["server.worktree"].endpoints
|
||||
const context = yield* Effect.context<Scope.Scope>()
|
||||
const runtime = yield* Effect.context<Scope.Scope>()
|
||||
const streams = yield* makeStreams()
|
||||
|
||||
// Run a hook registration on the plugin scope and resolve once it is registered.
|
||||
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
|
||||
Effect.runPromiseWith(context)(effect).then((registration) => ({
|
||||
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
|
||||
Effect.runPromiseWith(runtime)(effect).then((registration) => ({
|
||||
dispose: () => Effect.runPromiseWith(runtime)(registration.dispose),
|
||||
}))
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
|
||||
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(runtime)(effect)
|
||||
|
||||
const promiseExecutor =
|
||||
(execute: Tool.Info["execute"]): Info["execute"] =>
|
||||
(input, context) =>
|
||||
run(
|
||||
Effect.runPromiseWith(runtime)(
|
||||
execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.promise(() => context.progress(update)),
|
||||
}),
|
||||
{ signal: context.signal },
|
||||
)
|
||||
|
||||
const adaptApiMethod = <PromiseMethod>(
|
||||
@@ -273,7 +274,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
const result = yield* method(Object.assign({}, ...decoded) as never)
|
||||
if (compiled.noContent) return undefined
|
||||
return yield* compiled.encode(result)
|
||||
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
|
||||
}).pipe(Effect.runPromiseWith(runtime))) as PromiseMethod
|
||||
}
|
||||
|
||||
const transform =
|
||||
@@ -424,8 +425,8 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
|
||||
active: (id) => Effect.runPromiseWith(runtime)(host.integration.connection.active(id)),
|
||||
resolve: (connection) => Effect.runPromiseWith(runtime)(host.integration.connection.resolve(connection)),
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
@@ -612,9 +613,10 @@ function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
|
||||
type RuntimeSchema = Schema.Codec<unknown, unknown>
|
||||
|
||||
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
|
||||
Effect.promise(() =>
|
||||
Effect.promise((signal) =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
signal,
|
||||
progress: (update) => Effect.runPromise(context.progress(update), { signal }),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Types } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolContext extends Omit<Tool.Context, "progress"> {
|
||||
readonly signal: AbortSignal
|
||||
readonly progress: (update: Tool.Metadata) => Promise<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user