From 897da9397bd55ac5f2c5443e223f4292088508f7 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Wed, 23 Sep 2026 18:56:25 -0400 Subject: [PATCH 1/6] chore(app-bundle): sync overlay to upstream opencode v1.18.30 Bump base from v1.18.29 (7fe9938) to v1.18.30 (3104c14). 413 upstream commits flow through automatically for non-overlaid paths. Overlay files reconciled against upstream v1.18.30 via three-way merge: - provider.ts: take upstream (AI Gateway native routing, Vertex EU/US, anthropicBlockBinding, ARN passthrough, 5min timeouts, Azure OAuth); preserve our defaultModel provider-availability guard + deepseek broadened prefix match - transform.ts: take upstream (anthropicBlockBinding, MergeGateway, GitLab reasoning, DeepSeek v4 topP, textVerbosity guard); preserve our Bedrock reasoning-part text fallback, applyBedrockCacheOptions, empty-content guard, Qwen sampling defaults - dialog-connect-provider.tsx: take upstream i18n method hints; preserve Harmoniqs provider integration, browser OAuth UX, design system tokens - models-dev.ts: no change needed (3-line comment already preserved) --- packages/app-bundle/manifest.json | 12 +-- .../components/dialog-connect-provider.tsx | 9 +- .../opencode/src/provider/provider.ts | 83 ++++++++++++++++--- .../opencode/src/provider/transform.ts | 73 ++++++++++++++-- packages/extension/opencode.lock.json | 8 +- 5 files changed, 157 insertions(+), 28 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 9aa3adbbe..fd286e451 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -4,8 +4,8 @@ "overlay_ref": "local/amicode", "overlay_tag": "v1.18.29-amicode.30-67-g8608dcc1fe", "overlay_sha": "8608dcc1fe9dfadbe05acdcca2f1a944620d1fb8", - "upstream_base": "7fe993879f98aa17cecc70f70d3f40d6f0f11689", - "upstream_base_sha": "7fe993879f98aa17cecc70f70d3f40d6f0f11689", + "upstream_base": "3104c1428ec91f809e5ab86631300de41eb6952e", + "upstream_base_sha": "3104c1428ec91f809e5ab86631300de41eb6952e", "promoted_at": "2026-09-13T22:15:25.839Z", "files": { "packages/app/CONTEXT.md": "7dc4bf176a32ee578a54593a58a6b97907ffc5a9baf7ae7c16a8fa920d8e6e86", @@ -103,7 +103,7 @@ "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", "packages/app/src/components/dialog-connect-provider-harmoniqs.test.ts": "7c16914afe3e77ad09691bbfa8223981eda0c2faf3616ed6db2e4e5fa9ce9fdd", "packages/app/src/components/dialog-connect-provider-harmoniqs.ts": "2f070abc5d0274ed1403f963ae0cb0af6e0f635f3732414d757d1b7ba06ea313", - "packages/app/src/components/dialog-connect-provider.tsx": "6f23cb5edda411c12606659109c3943f0cd218b9bcba29310f4ea3cda4eea98c", + "packages/app/src/components/dialog-connect-provider.tsx": "6eb784f06171e3c22d934fd170199b6b1020295a365b669aa8581c86b464b77d", "packages/app/src/components/dialog-edit-project-v2.tsx": "f34aaed28604facd9d8a46a4934e2b331421d5f60902a2b160ddcec11af03bd9", "packages/app/src/components/dialog-edit-project.tsx": "5371da3de3a643d4347629af55c0adc2476bffe7b2f061535a92fd67e756e562", "packages/app/src/components/dialog-new-project.tsx": "102fc33e8eeaf3c76ddd1b953b7b97995d30c02a3579f8b7036e47448a1582a8", @@ -288,8 +288,8 @@ "packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab", "packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8", "packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f", - "packages/opencode/src/provider/provider.ts": "671f6d8de83f919ddaa69b8240cacd82e2b8202ab505d4334175f2858bbd8c32", - "packages/opencode/src/provider/transform.ts": "d35a95a6092b5dd9cc8e37adb4d52f9ad9880d021cb3119c1d4abbc4b4fb0772", + "packages/opencode/src/provider/provider.ts": "c4926c901ff39c4e6296e8cdb3bf131e7dbd5900f34cf3093af60da8e4f4664a", + "packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8", "packages/opencode/src/server/server.ts": "56b3c2047917aa80d1dfb29bfa9839bcc2ad7f5a6f4db12103cd962a98fba866", "packages/opencode/src/session/business-record.ts": "d82db061055d0dd26ad95b34bff688ce23fd06a8fb9248dd335c6726c6ff0f5e", "packages/opencode/src/session/compaction.ts": "fd6159a8b04050706b6968c7a1b091f79d06e6e4aa988a12629a7616b8128e94", @@ -1781,5 +1781,5 @@ "packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M" }, - "upstream_base_archive_sha256": "6ae708a289bf8957f928e59ec5f3fc8a61e56290a9f12f47b916c9a26a517e9b" + "upstream_base_archive_sha256": "4b5f2e5edfc796082a353fd09c1b1dec3e7a108a4b080fe3a86b12d9fa59fae6" } diff --git a/packages/app-bundle/overlay/packages/app/src/components/dialog-connect-provider.tsx b/packages/app-bundle/overlay/packages/app/src/components/dialog-connect-provider.tsx index c6294d23c..de074bb9c 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/dialog-connect-provider.tsx @@ -430,9 +430,9 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() - const platform = usePlatform() const params = useParams() const language = useLanguage() + const platform = usePlatform() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const providers = useProviders(() => props.directory?.()) @@ -556,7 +556,12 @@ function ProviderConnection(props: { const hint = suffix?.[1] return { label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, + hint: + hint?.toLowerCase() === "headless" + ? language.t("provider.connect.method.headless") + : hint?.toLowerCase() === "browser" || (!hint && value?.type === "key") + ? language.t("provider.connect.method.browser") + : undefined, } } diff --git a/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts b/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts index 639b68ae8..7a74b0bae 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts @@ -46,7 +46,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { const id = setTimeout(() => { const err = new ProviderError.ResponseStreamError("SSE read timed out") ctl.abort(err) - void reader.cancel(err) + reader.cancel(err).catch(() => {}) reject(err) }, ms) @@ -98,6 +98,12 @@ function googleVertexAnthropicBaseURL(project: string | undefined, location: str return `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models` } +function googleVertexEndpoint(location: string) { + if (location === "global") return "aiplatform.googleapis.com" + if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com` + return `${location}-aiplatform.googleapis.com` +} + type BundledSDK = { languageModel(modelId: string): LanguageModelV3 chat?: (modelId: string) => LanguageModelV3 @@ -244,6 +250,7 @@ function custom(dep: CustomDep): Record { return [ provider.options?.resourceName, auth?.type === "api" ? auth.metadata?.resourceName : undefined, + auth?.type === "oauth" ? auth.accountId : undefined, env["AZURE_RESOURCE_NAME"], ].find((name) => typeof name === "string" && name.trim() !== "") }) @@ -369,6 +376,10 @@ function custom(dep: CustomDep): Record { // Skip region prefixing if model already has a cross-region inference profile prefix // Models from models.dev may already include prefixes like us., eu., global., etc. + if (modelID.startsWith("arn:")) { + return sdk.languageModel(modelID) + } + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) { return sdk.languageModel(modelID) @@ -519,11 +530,10 @@ function custom(dep: CustomDep): Record { return { autoload: true, vars(_options: Record) { - const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` return { ...(project && { GOOGLE_VERTEX_PROJECT: project }), GOOGLE_VERTEX_LOCATION: location, - GOOGLE_VERTEX_ENDPOINT: endpoint, + GOOGLE_VERTEX_ENDPOINT: googleVertexEndpoint(location), } }, options: { @@ -800,9 +810,11 @@ function custom(dep: CustomDep): Record { ) } - // Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility) const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")) const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")) + const { createOpenAI } = yield* Effect.promise(() => import("ai-gateway-provider/providers/openai")) + const { createAnthropic } = yield* Effect.promise(() => import("ai-gateway-provider/providers/anthropic")) + const { createOpenAICompatible } = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) const metadata = iife(() => { if (input.options?.metadata) return input.options.metadata @@ -829,13 +841,43 @@ function custom(dep: CustomDep): Record { apiKey: apiToken, ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), }) - const unified = createUnified({ apiKey: apiToken }) - return { autoload: true, async getModel(_sdk: any, modelID: string, _options?: Record) { - // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5") - return aigateway(unified(modelID)) + // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5"). + // OpenAI and Anthropic ride their native passthrough routes so agents get the Responses + // and Messages APIs; new OpenAI models reject tools+reasoning_effort on chat completions. + // The passthrough wrappers inject a CF_TEMP_TOKEN sentinel that the gateway strips before + // dispatch, so upstream billing stays on the gateway (Unified Billing / stored BYOK). + if (modelID.startsWith("openai/")) return aigateway(createOpenAI()(modelID.slice("openai/".length))) + // models.dev lists Anthropic ids with dotted versions (claude-haiku-4.5); Anthropic's + // Messages API expects dashed native slugs (claude-haiku-4-5), so translate before passing. + // No native Anthropic slug contains a dot, so the blanket replacement is lossless here - + // unlike OpenAI above, whose native ids (e.g. gpt-4.1) keep their dots and must not be touched. + if (modelID.startsWith("anthropic/")) + return aigateway(createAnthropic()(modelID.slice("anthropic/".length).replaceAll(".", "-"))) + // Workers AI is the only first-party provider whose upstream is Cloudflare itself, so it is + // the only one that should receive the Cloudflare token as its upstream Authorization header. + // The Unified API addresses Workers AI both with the explicit "workers-ai/" prefix and as + // bare "@cf/..." ids. Third-party providers must not receive the token; they rely on the + // gateway's stored/BYOK keys instead. + // Workers AI is Cloudflare's own upstream, so it rides the unified compat route with the + // Cloudflare token as its upstream Authorization header. + const isWorkersAi = modelID.startsWith("workers-ai/") || modelID.startsWith("@cf/") + if (isWorkersAi) return aigateway(createUnified({ apiKey: apiToken })(modelID)) + + // Every other third-party provider (google, xai, alibaba, deepseek, moonshotai, …) is only + // served by Cloudflare's catalog-aware REST API. The universal/compat gateway route rejects + // them with "Invalid provider" (the gateway's compat endpoint doesn't front those upstreams), + // so point an OpenAI-compatible client at the REST endpoint and bind it to the gateway with + // cf-aig-gateway-id — that keeps requests gateway-routed (analytics/caching/BYOK), not a + // bypass. models.dev ids (provider/model, dotted) pass through unchanged. + return createOpenAICompatible({ + name: "cloudflare-ai-gateway", + baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, + apiKey: apiToken, + headers: { "cf-aig-gateway-id": gateway }, + })(modelID) }, options: {}, } @@ -1209,6 +1251,17 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } +// Cloudflare AI Gateway routes OpenAI and Anthropic models through their native +// passthrough SDKs (Responses / Messages APIs). Resolving the native npm before +// variants are computed makes reasoning variants produce payloads the native +// SDKs understand (e.g. anthropic `effort` instead of compat `reasoningEffort`). +function cloudflareGatewayNpm(providerID: string, modelID: string) { + if (providerID !== "cloudflare-ai-gateway") return undefined + if (modelID.startsWith("openai/")) return "@ai-sdk/openai" + if (modelID.startsWith("anthropic/")) return "@ai-sdk/anthropic" + return undefined +} + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { id: ModelV2.ID.make(model.id), @@ -1218,7 +1271,11 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model api: { id: model.id, url: model.provider?.api ?? provider.api ?? "", - npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", + npm: + cloudflareGatewayNpm(provider.id, model.id) ?? + model.provider?.npm ?? + provider.npm ?? + "@ai-sdk/openai-compatible", }, status: model.status ?? "active", headers: {}, @@ -1440,6 +1497,9 @@ const layer = Layer.effect( model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? + // Config-defined gateway models bypass fromModelsDevModel, so resolve the + // native passthrough npm here before falling back to the catalog default. + cloudflareGatewayNpm(providerID, apiID) ?? modelsDev[providerID]?.npm ?? "@ai-sdk/openai-compatible" const name = iife(() => { @@ -1619,6 +1679,7 @@ const layer = Layer.effect( for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID + if ( // These chat aliases are invalid for the special handling in the // built-in providers below, but custom providers may support them. @@ -1735,8 +1796,8 @@ const layer = Layer.effect( if (existing) return existing const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] - const headerTimeout = options["headerTimeout"] + const chunkTimeout = options["chunkTimeout"] ?? 300_000 + const headerTimeout = options["headerTimeout"] ?? 300_000 delete options["chunkTimeout"] delete options["headerTimeout"] diff --git a/packages/app-bundle/overlay/packages/opencode/src/provider/transform.ts b/packages/app-bundle/overlay/packages/opencode/src/provider/transform.ts index 61c1c49f7..2b8971d93 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/provider/transform.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/provider/transform.ts @@ -84,6 +84,8 @@ function sdkKey(npm: string): string | undefined { return "gateway" case "@openrouter/ai-sdk-provider": return "openrouter" + case "merge-gateway-ai-sdk-provider": + return "mergeGateway" case "ai-gateway-provider": // ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }), // and @ai-sdk/openai-compatible parses compatibleOptions from one of @@ -602,6 +604,12 @@ export function topP(model: Provider.Model) { if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { return 0.95 } + if ( + ["deepseek-v4-flash-0731", "deepseek-v4-flash:0731"].some((name) => id.includes(name)) || + (id.includes("deepseek-v4-flash") && (model.providerID === "deepseek" || model.providerID.startsWith("opencode"))) + ) { + return 0.95 + } return undefined } @@ -730,6 +738,57 @@ function anthropicOmitsThinking(apiId: string) { return anthropicUsesModernAdaptiveThinking(apiId) } +// Default to binding controls for Claude 5.1+ as enforcement expands to later models. +// Mythos 5.1 explicitly does not run the conversation-prefix check. +// https://platform.claude.com/docs/en/build-with-claude/thinking#preserved-in-conversation +function anthropicBindsThinking(apiId: string) { + // Capture either family/version order, without reading release dates as minor versions. + const version = /claude-(?:([a-z]+)-)?(\d+)(?:[.-](\d{1,2}))?(?:-([a-z]+))?(?:[.@-]|$)/i.exec(apiId) + if (!version) return false + const major = Number(version[2]) + const minor = Number(version[3] ?? 0) + if (major === 5 && minor === 1 && (version[1] ?? version[4])?.toLowerCase() === "mythos") return false + return major > 5 || (major === 5 && minor >= 1) +} + +// Fable 5.1 binds each thinking signature to the system prompt, tool list, and +// messages above it, and rejects the request when any of that changes. opencode +// re-renders parts of that prefix between turns (system prompt, tools, compaction), +// so ask the API to drop the affected blocks instead of failing the request. +// Older model deployments may reject this field, even with thinking enabled. +// The patched AI SDK adds the thinking-binding-controls beta whenever it is set. +const ANTHROPIC_BLOCK_BINDING = { prefixMismatchBehavior: "drop_block" } + +function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: any }) { + const sdk = sdkKey(model.api.npm) + const key = sdk === "bedrock" ? "reasoningConfig" : sdk === "anthropic" ? "thinking" : undefined + // Consume the OpenCode-only opt-out even on models outside the default scope. + if (key && options[key]?.blockBinding === false) { + const result = { ...options, [key]: { ...options[key] } } + delete result[key].blockBinding + if (Object.keys(result[key]).length === 0) delete result[key] + return result + } + + if (!anthropicBindsThinking(model.api.id)) return options + switch (model.api.npm) { + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": { + const thinking = options.thinking ?? { type: "adaptive" } + if (thinking.type !== "adaptive" && thinking.type !== "enabled") return options + if (thinking.blockBinding !== undefined) return options + return { ...options, thinking: { ...thinking, blockBinding: ANTHROPIC_BLOCK_BINDING } } + } + case "@ai-sdk/amazon-bedrock": { + const reasoningConfig = options.reasoningConfig ?? { type: "adaptive" } + if (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled") return options + if (reasoningConfig.blockBinding !== undefined) return options + return { ...options, reasoningConfig: { ...reasoningConfig, blockBinding: ANTHROPIC_BLOCK_BINDING } } + } + } + return options +} + function googleThinkingLevelEfforts(apiId: string) { const id = apiId.toLowerCase() if (!id.includes("gemini-3")) return ["low", "high"] @@ -1348,13 +1407,13 @@ export function options(input: { } } - // Only set textVerbosity for non-chat gpt-5.x models - // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity + // Generic OpenAI-compatible APIs do not necessarily support OpenAI's verbosity parameter. + // Only enable the default for integrations known to implement it. if ( input.model.api.id.includes("gpt-5.") && !input.model.api.id.includes("codex") && !input.model.api.id.includes("-chat") && - input.model.providerID !== "azure" + (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") ) { result["textVerbosity"] = "low" } @@ -1409,7 +1468,7 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a usesOpenAIReasoningGate && (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) ? { ...options, forceReasoning: true } - : options + : anthropicBlockBinding(model, options) if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: @@ -1817,12 +1876,16 @@ function reasoningEffort(model: Provider.Model, effort: string) { case "@ai-sdk/togetherai": case "venice-ai-sdk-provider": case "ai-gateway-provider": + case "merge-gateway-ai-sdk-provider": return { reasoningEffort: effort } + case "gitlab-ai-provider": + if (model.family?.startsWith("gpt")) return { reasoningEffort: effort } + if (model.family?.startsWith("claude")) return { thinking: { type: "adaptive", effort } } + return case "@ai-sdk/cohere": case "@ai-sdk/perplexity": case "@ai-sdk/vercel": case "@ai-sdk/alibaba": - case "gitlab-ai-provider": return } } diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 6f7b8fb10..7de6b6507 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,6 +1,6 @@ { - "version": "1.18.29", - "base_version": "1.18.29", - "base_commit": "7fe993879f98aa17cecc70f70d3f40d6f0f11689", - "overlay_hash": "09a343d884456f3cc01af38e55d42f4085251896f86ed44a5ba1fed05b086b88" + "version": "1.18.30", + "base_version": "1.18.30", + "base_commit": "3104c1428ec91f809e5ab86631300de41eb6952e", + "overlay_hash": "74e1bc11402ef8dd0d7a43b8d65430e076d5b3caf8416cc7c4e91c9ac3adf55e" } From c26ea4634c82941f61e515967c753c4cedb02ef1 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Wed, 23 Sep 2026 19:16:51 -0400 Subject: [PATCH 2/6] fix(app-bundle): repair CI for the v1.18.30 overlay sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI gates + one CodeRabbit finding: - engine-tests: refresh the base-drift allowlist for v1.18.30 (codex extractResidency TS2305 joins the 3 existing MCP/HttpRecorder errors — all upstream test-vs-src inconsistencies, not overlay-caused) - fast: bump hardcoded 1.18.29 -> 1.18.30 in the committed-manifest assertions (fetch_opencode, amicode_service_contract) - vsix-gate: the app vite build broke because our old monolithic marked.tsx imported ./marked-code-span, which upstream v1.18.30 dropped when it split marked.tsx into marked-parser.tsx. Port our physics KaTeX macros (\Tr, \ket, \bra, \braket) + single-$ inline math into a new marked-parser.tsx overlay; drop our now-identical marked.tsx overlay; repoint marked-math.test.ts at createMarkdownParser - CodeRabbit #1506: the CF AI Gateway third-party REST fallback dropped the cf-aig-* options (caching/logging/metadata). Forward them as headers alongside cf-aig-gateway-id Verified locally: build:binary green (v1.18.30), build:app green, drift-gate PASS, engine typecheck gate PASS, marked-math 8/8. --- packages/app-bundle/manifest.json | 8 +- .../opencode/src/provider/provider.ts | 12 +- .../ui/src/context/marked-math.test.ts | 9 +- .../packages/ui/src/context/marked-parser.tsx | 162 +++++ .../packages/ui/src/context/marked.tsx | 655 ------------------ .../scripts/engine_typecheck_gate.sh | 23 +- packages/extension/opencode.lock.json | 2 +- .../test/amicode_service_contract.test.ts | 2 +- .../extension/test/fetch_opencode.test.ts | 4 +- 9 files changed, 199 insertions(+), 678 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/ui/src/context/marked-parser.tsx delete mode 100644 packages/app-bundle/overlay/packages/ui/src/context/marked.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index fd286e451..1007d2443 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -288,7 +288,7 @@ "packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab", "packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8", "packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f", - "packages/opencode/src/provider/provider.ts": "c4926c901ff39c4e6296e8cdb3bf131e7dbd5900f34cf3093af60da8e4f4664a", + "packages/opencode/src/provider/provider.ts": "abe4bcfd92c7b1d24760d6a8ad9dd97e12fe3278e0168edb19c3b8d3da497ef2", "packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8", "packages/opencode/src/server/server.ts": "56b3c2047917aa80d1dfb29bfa9839bcc2ad7f5a6f4db12103cd962a98fba866", "packages/opencode/src/session/business-record.ts": "d82db061055d0dd26ad95b34bff688ce23fd06a8fb9248dd335c6726c6ff0f5e", @@ -585,8 +585,8 @@ "packages/ui/src/components/toast.css": "60c06eda9f0ccc5641c03c002a6080db99c00d797dbdf7511c789089efc1c546", "packages/ui/src/components/tooltip.css": "a615df9b86e64c1565384baa8054c2f80ebad11856d33f5e6f53902541eacef2", "packages/ui/src/context/dialog.tsx": "876f8a5dc26ebb98f1f5e9e528e7d0bd7aa79c0a107b62027dd9fe78ed2cc620", - "packages/ui/src/context/marked-math.test.ts": "0fe460c09956335a469b382ff631809381875465cbcaf7e7c599f86240b0227c", - "packages/ui/src/context/marked.tsx": "b4f693cba3f8020596f860a00293e43855ec75d0bd214e379e36250d02e2be4c", + "packages/ui/src/context/marked-math.test.ts": "6edcbe85175366da339d8d921b0f98834e95ea9cf500e91dfae7c2c738bdf902", + "packages/ui/src/context/marked-parser.tsx": "90b79a684f9c0b7afff63bb5776512779eb3912e76f85a7bb2e2d97bb575d4b9", "packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711", "packages/ui/src/i18n/br.ts": "cbde2da43ef820fe2664cb96cb973fd722156a6c8a3c5e16d18638bf014e5196", "packages/ui/src/i18n/bs.ts": "23ea0ae34a4edaa4599b13f63f481be861e28da3b0d898987e291c7e91a9f11e", @@ -1506,7 +1506,7 @@ "packages/ui/src/components/tooltip.css": "M", "packages/ui/src/context/dialog.tsx": "M", "packages/ui/src/context/marked-math.test.ts": "A", - "packages/ui/src/context/marked.tsx": "M", + "packages/ui/src/context/marked-parser.tsx": "M", "packages/ui/src/i18n/ar.ts": "M", "packages/ui/src/i18n/br.ts": "M", "packages/ui/src/i18n/bs.ts": "M", diff --git a/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts b/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts index 7a74b0bae..c68c0eeb6 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/provider/provider.ts @@ -872,11 +872,21 @@ function custom(dep: CustomDep): Record { // so point an OpenAI-compatible client at the REST endpoint and bind it to the gateway with // cf-aig-gateway-id — that keeps requests gateway-routed (analytics/caching/BYOK), not a // bypass. models.dev ids (provider/model, dotted) pass through unchanged. + // The native routes above get their gateway options via createAiGateway's `options`; this + // REST fallback bypasses that wrapper, so forward the same options as their cf-aig-* header + // equivalents (CodeRabbit #1506) — otherwise caching/logging/metadata are silently dropped + // for third-party models. + const cfAigHeaders: Record = { "cf-aig-gateway-id": gateway } + if (opts.metadata !== undefined) cfAigHeaders["cf-aig-metadata"] = JSON.stringify(opts.metadata) + if (opts.cacheTtl !== undefined) cfAigHeaders["cf-aig-cache-ttl"] = String(opts.cacheTtl) + if (opts.cacheKey !== undefined) cfAigHeaders["cf-aig-cache-key"] = String(opts.cacheKey) + if (opts.skipCache !== undefined) cfAigHeaders["cf-aig-skip-cache"] = String(opts.skipCache) + if (opts.collectLog !== undefined) cfAigHeaders["cf-aig-collect-log"] = String(opts.collectLog) return createOpenAICompatible({ name: "cloudflare-ai-gateway", baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, apiKey: apiToken, - headers: { "cf-aig-gateway-id": gateway }, + headers: cfAigHeaders, })(modelID) }, options: {}, diff --git a/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts b/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts index 05338bcb2..c9e97a37e 100644 --- a/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts +++ b/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts @@ -1,9 +1,10 @@ import { expect, test } from "bun:test" -import { Marked } from "marked" -import { markedCodeSpanBoundary } from "./marked-code-span" -import { katexExtension, renderMathInText } from "./marked" +import { createMarkdownParser, renderMathInText } from "./marked-parser" -const parse = (src: string) => new Marked(markedCodeSpanBoundary, katexExtension).parse(src) +// Parse through the real production pipeline (createMarkdownParser wires the +// katexExtension + markedShiki). A no-op highlighter keeps the test hermetic — +// code spans still round-trip as via marked's built-in inline handling. +const parse = (src: string) => createMarkdownParser((code) => code).parse(src) test("renders single-$ inline math", async () => { const html = await parse("in the rotating frame, where $\\Omega_x, \\Omega_y$ are the drives") diff --git a/packages/app-bundle/overlay/packages/ui/src/context/marked-parser.tsx b/packages/app-bundle/overlay/packages/ui/src/context/marked-parser.tsx new file mode 100644 index 000000000..327abb601 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/context/marked-parser.tsx @@ -0,0 +1,162 @@ +import katex from "katex" +import { Marked, type MarkedExtension, type Tokens } from "marked" +import markedShiki from "marked-shiki" + +// KaTeX ships no \Tr, \tr, braket, etc. — they are LaTeX packages (physics / +// \DeclareMathOperator), not core TeX — so an assistant writing quantum-control +// math ("\Tr(\rho)", "\ket{0}") produced red "undefined control sequence" +// errors. Register the operators/notation as macros so chat math renders. This +// is passed to every KaTeX render in this module via renderKatexToken. +const KATEX_MACROS: Record = { + "\\Tr": "\\operatorname{Tr}", + "\\tr": "\\operatorname{tr}", + "\\rank": "\\operatorname{rank}", + "\\diag": "\\operatorname{diag}", + "\\ket": "{\\left|#1\\right\\rangle}", + "\\bra": "{\\left\\langle#1\\right|}", + "\\braket": "{\\left\\langle#1\\right\\rangle}", + "\\ketbra": "{\\left|#1\\right\\rangle\\!\\left\\langle#2\\right|}", +} + +export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise) { + return new Marked( + { + renderer: { + link({ href, title, text }) { + const titleAttr = title ? ` title="${title}"` : "" + return `${text}` + }, + }, + }, + katexExtension, + markedShiki({ highlight }), + ) +} + +// Single-$ inline math — restored after #34850 removed it for currency false +// positives. Pandoc-style tight delimiters (no whitespace just inside either +// $), no digit-led content (so $5, and $30-and-$50 pairs, stay literal), no +// $$ adjacency, no escaped \$. One regex family, three shapes: the tokenizer +// start hint, the anchored tokenizer match, and the global replace below. +const singleDollarStartRegex = /(? { + try { + return katex.renderToString(math, { + displayMode: true, + throwOnError: false, + macros: KATEX_MACROS, + }) + } catch { + return `$$${math}$$` + } + }) + + // Inline math: \(...\) + const inlineMathReplaceRegex = /\\\(((?:\\.|[^\\\n])*?)\\\)/g + result = result.replace(inlineMathReplaceRegex, (_, math) => { + try { + return katex.renderToString(math, { + displayMode: false, + throwOnError: false, + macros: KATEX_MACROS, + }) + } catch { + return `\\(${math}\\)` + } + }) + + // Inline math: $...$ (guarded — see singleDollarInlineRegex above) + result = result.replace(singleDollarInlineRegex, (_, math) => { + try { + return katex.renderToString(math, { + displayMode: false, + throwOnError: false, + macros: KATEX_MACROS, + }) + } catch { + return `$${math}$` + } + }) + + return result +} + +const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/ +const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/ + +export const katexExtension: MarkedExtension = { + extensions: [ + { + name: "inlineKatex", + level: "inline", + start(src) { + const index = src.indexOf("\\(") + if (index === -1) return + return index + }, + tokenizer(src) { + const match = src.match(inlineMathRegex) + if (!match) return + return { + type: "inlineKatex", + raw: match[0], + text: match[1].trim(), + displayMode: false, + } + }, + renderer: renderKatexToken, + }, + { + name: "blockKatex", + level: "block", + tokenizer(src) { + const match = src.match(blockMathRegex) + if (!match) return + return { + type: "blockKatex", + raw: match[0], + text: match[1].trim(), + displayMode: true, + } + }, + renderer: renderKatexToken, + }, + { + // Single-$ inline math (guarded, see singleDollar*Regex above). The + // close-side (?!\$) keeps this from ever eating one half of $$..$$. + name: "singleDollarKatex", + level: "inline", + start(src) { + const match = src.match(singleDollarStartRegex) + return match ? match.index : undefined + }, + tokenizer(src) { + const match = src.match(singleDollarTokenizerRegex) + if (!match) return + return { + type: "singleDollarKatex", + raw: match[0], + text: match[1].trim(), + displayMode: false, + } + }, + renderer: renderKatexToken, + }, + ], +} + +function renderKatexToken(token: Tokens.Generic) { + return katex.renderToString(typeof token.text === "string" ? token.text : "", { + displayMode: token.displayMode === true, + throwOnError: false, + macros: KATEX_MACROS, + }) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/context/marked.tsx b/packages/app-bundle/overlay/packages/ui/src/context/marked.tsx deleted file mode 100644 index e4a00db7d..000000000 --- a/packages/app-bundle/overlay/packages/ui/src/context/marked.tsx +++ /dev/null @@ -1,655 +0,0 @@ -import { marked, type MarkedExtension, type Tokens } from "marked" -import markedShiki from "marked-shiki" -import katex from "katex" -import { bundledLanguages, type BundledLanguage } from "shiki" -import { createSimpleContext } from "./helper" -import { markedCodeSpanBoundary } from "./marked-code-span" -import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" - -// KaTeX ships no \Tr, \tr, braket, etc. — they are LaTeX packages (physics / -// \DeclareMathOperator), not core TeX — so an assistant writing quantum-control -// math ("\Tr(\rho)", "\ket{0}") produced red "undefined control sequence" -// errors. Register the operators/notation as macros so chat math renders. This -// is passed to every KaTeX render in this module via renderKatexToken. -const KATEX_MACROS: Record = { - "\\Tr": "\\operatorname{Tr}", - "\\tr": "\\operatorname{tr}", - "\\rank": "\\operatorname{rank}", - "\\diag": "\\operatorname{diag}", - "\\ket": "{\\left|#1\\right\\rangle}", - "\\bra": "{\\left\\langle#1\\right|}", - "\\braket": "{\\left\\langle#1\\right\\rangle}", - "\\ketbra": "{\\left|#1\\right\\rangle\\!\\left\\langle#2\\right|}", -} - - // "gitDecoration.conflictingResourceForeground": "#ffca00", - // "gitDecoration.modifiedResourceForeground": "#1a76d4", - // "gitDecoration.untrackedResourceForeground": "#00cab1", - // "gitDecoration.ignoredResourceForeground": "#84848A", - // "terminal.titleForeground": "#adadb1", - // "terminal.titleInactiveForeground": "#84848A", - // "terminal.background": "#141415", - // "terminal.foreground": "#adadb1", - // "terminal.ansiBlack": "#141415", - // "terminal.ansiRed": "#ff2e3f", - // "terminal.ansiGreen": "#0dbe4e", - // "terminal.ansiYellow": "#ffca00", - // "terminal.ansiBlue": "#008cff", - // "terminal.ansiMagenta": "#c635e4", - // "terminal.ansiCyan": "#08c0ef", - // "terminal.ansiWhite": "#c6c6c8", - // "terminal.ansiBrightBlack": "#141415", - // "terminal.ansiBrightRed": "#ff2e3f", - // "terminal.ansiBrightGreen": "#0dbe4e", - // "terminal.ansiBrightYellow": "#ffca00", - // "terminal.ansiBrightBlue": "#008cff", - // "terminal.ansiBrightMagenta": "#c635e4", - // "terminal.ansiBrightCyan": "#08c0ef", -export const OpenCodeTheme = { - name: "OpenCode", - bg: "var(--color-background-stronger)", - fg: "var(--text-base)", - colors: { - "editor.background": "var(--color-background-stronger)", - "editor.foreground": "var(--text-base)", - "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", - "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", - "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", - // "gitDecoration.conflictingResourceForeground": "#ffca00", - // "gitDecoration.modifiedResourceForeground": "#1a76d4", - // "gitDecoration.untrackedResourceForeground": "#00cab1", - // "gitDecoration.ignoredResourceForeground": "#84848A", - // "terminal.titleForeground": "#adadb1", - // "terminal.titleInactiveForeground": "#84848A", - // "terminal.background": "#141415", - // "terminal.foreground": "#adadb1", - // "terminal.ansiBlack": "#141415", - // "terminal.ansiRed": "#ff2e3f", - // "terminal.ansiGreen": "#0dbe4e", - // "terminal.ansiYellow": "#ffca00", - // "terminal.ansiBlue": "#008cff", - // "terminal.ansiMagenta": "#c635e4", - // "terminal.ansiCyan": "#08c0ef", - // "terminal.ansiWhite": "#c6c6c8", - // "terminal.ansiBrightBlack": "#141415", - // "terminal.ansiBrightRed": "#ff2e3f", - // "terminal.ansiBrightGreen": "#0dbe4e", - // "terminal.ansiBrightYellow": "#ffca00", - // "terminal.ansiBrightBlue": "#008cff", - // "terminal.ansiBrightMagenta": "#c635e4", - // "terminal.ansiBrightCyan": "#08c0ef", - // "terminal.ansiBrightWhite": "#c6c6c8", - }, - tokenColors: [ - { - scope: ["comment", "punctuation.definition.comment", "string.comment"], - settings: { - foreground: "var(--syntax-comment)", - }, - }, - { - scope: ["entity.other.attribute-name"], - settings: { - foreground: "var(--syntax-property)", // maybe attribute - }, - }, - { - scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], - settings: { - foreground: "var(--syntax-constant)", - }, - }, - { - scope: ["entity.name", "meta.export.default", "meta.definition.variable"], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: ["meta.object.member"], - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: [ - "variable.parameter.function", - "meta.jsx.children", - "meta.block", - "meta.tag.attributes", - "entity.name.constant", - "meta.embedded.expression", - "meta.template.expression", - "string.other.begin.yaml", - "string.other.end.yaml", - ], - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["entity.name.function", "support.type.primitive"], - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: ["support.class.component"], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: "keyword", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "keyword.operator", - "storage.type.function.arrow", - "punctuation.separator.key-value.css", - "entity.name.tag.yaml", - "punctuation.separator.key-value.mapping.yaml", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["storage", "storage.type"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: [ - "string", - "punctuation.definition.string", - "string punctuation.section.embedded source", - "entity.name.tag", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "support", - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], - settings: { - foreground: "var(--syntax-object)", - }, - }, - { - scope: "meta.property-name", - settings: { - foreground: "var(--syntax-property)", - }, - }, - { - scope: "variable", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "variable.other", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "invalid.broken", - "invalid.illegal", - "invalid.unimplemented", - "invalid.deprecated", - "message.error", - "markup.deleted", - "meta.diff.header.from-file", - "punctuation.definition.deleted", - "brackethighlighter.unmatched", - "token.error-token", - ], - settings: { - foreground: "var(--syntax-critical)", - }, - }, - { - scope: "carriage-return", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "string source", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "string variable", - settings: { - foreground: "var(--syntax-constant)", - }, - }, - { - scope: [ - "source.regexp", - "string.regexp", - "string.regexp.character-class", - "string.regexp constant.character.escape", - "string.regexp source.ruby.embedded", - "string.regexp string.regexp.arbitrary-repitition", - "string.regexp constant.character.escape", - ], - settings: { - foreground: "var(--syntax-regexp)", - }, - }, - { - scope: "support.constant", - settings: { - foreground: "var(--syntax-primitive)", - }, - }, - { - scope: "support.variable", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "meta.module-reference", - settings: { - foreground: "var(--syntax-info)", - }, - }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["markup.heading", "markup.heading entity.name"], - settings: { - fontStyle: "bold", - foreground: "var(--syntax-info)", - }, - }, - { - scope: "markup.quote", - settings: { - foreground: "var(--syntax-info)", - }, - }, - { - scope: "markup.italic", - settings: { - fontStyle: "italic", - // foreground: "", - }, - }, - { - scope: "markup.bold", - settings: { - fontStyle: "bold", - foreground: "var(--text-strong)", - }, - }, - { - scope: [ - "markup.raw", - "markup.inserted", - "meta.diff.header.to-file", - "punctuation.definition.inserted", - "markup.changed", - "punctuation.definition.changed", - "markup.ignored", - "markup.untracked", - ], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "meta.diff.range", - settings: { - fontStyle: "bold", - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.diff.header", - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.separator", - settings: { - fontStyle: "bold", - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.output", - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "meta.export.default", - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: [ - "brackethighlighter.tag", - "brackethighlighter.curly", - "brackethighlighter.round", - "brackethighlighter.square", - "brackethighlighter.angle", - "brackethighlighter.quote", - ], - settings: { - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: ["constant.other.reference.link", "string.other.link"], - settings: { - fontStyle: "underline", - foreground: "var(--syntax-unknown)", - }, - }, - { - scope: "token.info-token", - settings: { - foreground: "var(--syntax-info)", - }, - }, - { - scope: "token.warn-token", - settings: { - foreground: "var(--syntax-warning)", - }, - }, - { - scope: "token.debug-token", - settings: { - foreground: "var(--syntax-info)", - }, - }, - ], - semanticTokenColors: { - comment: "var(--syntax-comment)", - string: "var(--syntax-string)", - number: "var(--syntax-constant)", - regexp: "var(--syntax-regexp)", - keyword: "var(--syntax-keyword)", - variable: "var(--syntax-variable)", - parameter: "var(--syntax-variable)", - property: "var(--syntax-property)", - function: "var(--syntax-primitive)", - method: "var(--syntax-primitive)", - type: "var(--syntax-type)", - class: "var(--syntax-type)", - namespace: "var(--syntax-type)", - enumMember: "var(--syntax-primitive)", - "variable.constant": "var(--syntax-constant)", - "variable.defaultLibrary": "var(--syntax-unknown)", - }, -} as unknown as ThemeRegistrationResolved - -registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme)) - -// Single-$ inline math — restored after #34850 removed it for currency false -// positives. Pandoc-style tight delimiters (no whitespace just inside either -// $), no digit-led content (so $5, and $30-and-$50 pairs, stay literal), no -// $$ adjacency, no escaped \$. One regex family, three shapes: the tokenizer -// start hint, the anchored tokenizer match, and the global replace below. -const singleDollarStartRegex = /(? { - try { - return katex.renderToString(math, { - displayMode: true, - throwOnError: false, - macros: KATEX_MACROS, - }) - } catch { - return `$$${math}$$` - } - }) - - // Inline math: \(...\) - const inlineMathRegex = /\\\(((?:\\.|[^\\\n])*?)\\\)/g - result = result.replace(inlineMathRegex, (_, math) => { - try { - return katex.renderToString(math, { - displayMode: false, - throwOnError: false, - macros: KATEX_MACROS, - }) - } catch { - return `\\(${math}\\)` - } - }) - - // Inline math: $...$ (guarded — see singleDollarInlineRegex above) - result = result.replace(singleDollarInlineRegex, (_, math) => { - try { - return katex.renderToString(math, { - displayMode: false, - throwOnError: false, - macros: KATEX_MACROS, - }) - } catch { - return `$${math}$` - } - }) - - return result -} - -const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/ -const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/ - -export const katexExtension: MarkedExtension = { - extensions: [ - { - name: "inlineKatex", - level: "inline", - start(src) { - const index = src.indexOf("\\(") - if (index === -1) return - return index - }, - tokenizer(src) { - const match = src.match(inlineMathRegex) - if (!match) return - return { - type: "inlineKatex", - raw: match[0], - text: match[1].trim(), - displayMode: false, - } - }, - renderer: renderKatexToken, - }, - { - name: "blockKatex", - level: "block", - tokenizer(src) { - const match = src.match(blockMathRegex) - if (!match) return - return { - type: "blockKatex", - raw: match[0], - text: match[1].trim(), - displayMode: true, - } - }, - renderer: renderKatexToken, - }, - { - // Single-$ inline math (guarded, see singleDollar*Regex above). The - // close-side (?!\$) keeps this from ever eating one half of $$..$$. - name: "singleDollarKatex", - level: "inline", - start(src) { - const match = src.match(singleDollarStartRegex) - return match ? match.index : undefined - }, - tokenizer(src) { - const match = src.match(singleDollarTokenizerRegex) - if (!match) return - return { - type: "singleDollarKatex", - raw: match[0], - text: match[1].trim(), - displayMode: false, - } - }, - renderer: renderKatexToken, - }, - ], -} - -function renderKatexToken(token: Tokens.Generic) { - return katex.renderToString(typeof token.text === "string" ? token.text : "", { - displayMode: token.displayMode === true, - throwOnError: false, - macros: KATEX_MACROS, - }) -} - -function renderMathExpressions(html: string): string { - // Split on code/pre/kbd tags to avoid processing their contents - const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi - const parts = html.split(codeBlockPattern) - - return parts - .map((part, i) => { - // Odd indices are the captured code blocks - leave them alone - if (i % 2 === 1) return part - // Process math only in non-code parts - return renderMathInText(part) - }) - .join("") -} - -async function highlightCodeBlocks(html: string): Promise { - const codeBlockRegex = /
([\s\S]*?)<\/code><\/pre>/g
-  const matches = [...html.matchAll(codeBlockRegex)]
-  if (matches.length === 0) return html
-
-  const highlighter = await getSharedHighlighter({
-    themes: ["OpenCode"],
-    langs: [],
-    preferredHighlighter: "shiki-wasm",
-  })
-
-  let result = html
-  for (const match of matches) {
-    const [fullMatch, lang, escapedCode] = match
-    const code = escapedCode
-      .replace(/</g, "<")
-      .replace(/>/g, ">")
-      .replace(/&/g, "&")
-      .replace(/"/g, '"')
-      .replace(/'/g, "'")
-
-    let language = lang || "text"
-    if (!(language in bundledLanguages)) {
-      language = "text"
-    }
-    if (!highlighter.getLoadedLanguages().includes(language)) {
-      await highlighter.loadLanguage(language as BundledLanguage)
-    }
-
-    const highlighted = highlighter.codeToHtml(code, {
-      lang: language,
-      theme: "OpenCode",
-      tabindex: false,
-    })
-    result = result.replace(fullMatch, () => highlighted)
-  }
-
-  return result
-}
-
-export type NativeMarkdownParser = (markdown: string) => Promise
-
-export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
-  name: "Marked",
-  init: (props: { nativeParser?: NativeMarkdownParser }) => {
-    const jsParser = marked.use(
-      markedCodeSpanBoundary,
-      {
-        renderer: {
-          link({ href, title, text }) {
-            const titleAttr = title ? ` title="${title}"` : ""
-            return `${text}`
-          },
-        },
-      },
-      katexExtension,
-      markedShiki({
-        async highlight(code, lang) {
-          const highlighter = await getSharedHighlighter({
-            themes: ["OpenCode"],
-            langs: [],
-            preferredHighlighter: "shiki-wasm",
-          })
-          if (!(lang in bundledLanguages)) {
-            lang = "text"
-          }
-          if (!highlighter.getLoadedLanguages().includes(lang)) {
-            await highlighter.loadLanguage(lang as BundledLanguage)
-          }
-          return highlighter.codeToHtml(code, {
-            lang: lang || "text",
-            theme: "OpenCode",
-            tabindex: false,
-          })
-        },
-      }),
-    )
-
-    if (props.nativeParser) {
-      const nativeParser = props.nativeParser
-      return {
-        async parse(markdown: string): Promise {
-          const html = await nativeParser(markdown)
-          const withMath = renderMathExpressions(html)
-          return highlightCodeBlocks(withMath)
-        },
-      }
-    }
-
-    return jsParser
-  },
-})
diff --git a/packages/app-bundle/scripts/engine_typecheck_gate.sh b/packages/app-bundle/scripts/engine_typecheck_gate.sh
index 9174e8e8e..ef62fa959 100755
--- a/packages/app-bundle/scripts/engine_typecheck_gate.sh
+++ b/packages/app-bundle/scripts/engine_typecheck_gate.sh
@@ -8,12 +8,14 @@
 # (`bun turbo typecheck`), scoped to the `opencode` package — the engine where
 # amicode's overlay changes live.
 #
-# Any NEW type error fails CI. Exactly three KNOWN base-drift errors are
-# allowlisted (tracked in #1229): the manifest's upstream_base_sha=7fe9938 is
-# older than the base the overlay was authored against, so three opencode test
-# files reference symbols (MCP `remove`, HttpRecorder `promptAgnosticMatcher`)
-# absent from the stale base. #1229 bumps the base and DELETES this allowlist.
-# The full-monorepo lane and the unit-TEST lane are tracked in #1229 / #1233.
+# Any NEW type error fails CI. KNOWN base-drift errors are allowlisted: the
+# materialized upstream test tree references symbols that upstream's own src
+# tree does not export at the pinned base (e.g. codex `extractResidency`, MCP
+# `remove`, HttpRecorder `promptAgnosticMatcher`) — inconsistencies WITHIN the
+# upstream release, not caused by amicode's overlay. The allowlist is matched by
+# file + TS rule code so line drift does not defeat it. Refresh this list when
+# the base pin (manifest upstream_base) moves. The full-monorepo lane and the
+# unit-TEST lane are tracked in #1229 / #1233.
 set -uo pipefail
 
 MAT="packages/app-bundle/.materialized"
@@ -33,10 +35,11 @@ if ! echo "$OUT" | grep -q 'tsgo --noEmit'; then
   exit 1
 fi
 
-# Known base-drift errors, tracked in #1229. Matched by file + TS rule code so
-# line drift does not defeat the allowlist. Remove this whole block when #1229
-# bumps the base pin.
-ALLOW='test/server/httpapi-mcp-oauth\.test\.ts.*error TS2322|test/session/llm-native-recorded\.test\.ts.*error TS2339|test/session/snapshot-tool-race\.test\.ts.*error TS2741'
+# Known base-drift errors — upstream test files referencing symbols absent from
+# upstream's own src tree at the pinned base (upstream_base = v1.18.30). Matched
+# by file + TS rule code so line drift does not defeat the allowlist. Refresh
+# when the base pin moves.
+ALLOW='test/plugin/codex\.test\.ts.*error TS2305|test/server/httpapi-mcp-oauth\.test\.ts.*error TS2322|test/session/llm-native-recorded\.test\.ts.*error TS2339|test/session/snapshot-tool-race\.test\.ts.*error TS2741'
 
 TYPECHECK_ERRORS="$(echo "$OUT" | grep -E 'error TS' || true)"
 if [ "$TYPECHECK_STATUS" -ne 0 ] && [ -z "$TYPECHECK_ERRORS" ]; then
diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json
index 7de6b6507..6705b215b 100644
--- a/packages/extension/opencode.lock.json
+++ b/packages/extension/opencode.lock.json
@@ -2,5 +2,5 @@
   "version": "1.18.30",
   "base_version": "1.18.30",
   "base_commit": "3104c1428ec91f809e5ab86631300de41eb6952e",
-  "overlay_hash": "74e1bc11402ef8dd0d7a43b8d65430e076d5b3caf8416cc7c4e91c9ac3adf55e"
+  "overlay_hash": "407819851d4e37b0933ace5b70ec9be81127172a2e0e5a94fd2d9697ae5225e9"
 }
diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts
index edb7a1a65..401a7e994 100644
--- a/packages/extension/test/amicode_service_contract.test.ts
+++ b/packages/extension/test/amicode_service_contract.test.ts
@@ -219,7 +219,7 @@ describe("amicode service — golden-fixture parity with the fork", () => {
     expect(meta.entries.length).toBeGreaterThan(0);
     // Post-absorption: the lock has version + base_commit + overlay_hash.
     const lock = JSON.parse(readFileSync(fileURLToPath(new URL("../opencode.lock.json", import.meta.url)), "utf8"));
-    expect(lock.version).toBe("1.18.29");
+    expect(lock.version).toBe("1.18.30");
     expect(lock.base_commit).toMatch(/^[0-9a-f]{40}$/);
     expect(lock.overlay_hash).toMatch(/^[0-9a-f]{64}$/);
   });
diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts
index 74901af6c..bb2aa5226 100644
--- a/packages/extension/test/fetch_opencode.test.ts
+++ b/packages/extension/test/fetch_opencode.test.ts
@@ -22,11 +22,11 @@ describe("loadManifest", () => {
   });
   it("the COMMITTED manifest parses", () => {
     const m = loadManifest(); // defaults to the real packages/extension root
-    expect(m.version).toBe("1.18.29");
+    expect(m.version).toBe("1.18.30");
   });
   it("the committed manifest has the post-absorption schema fields", () => {
     const m = loadManifest();
-    expect(m.base_version).toBe("1.18.29");
+    expect(m.base_version).toBe("1.18.30");
     expect(m.base_commit).toMatch(/^[0-9a-f]{40}$/);
     expect(m.overlay_hash).toMatch(/^[0-9a-f]{64}$/);
   });

From 0b3466275f7f7dc7c3c70c1a499ecc654b130b2c Mon Sep 17 00:00:00 2001
From: Jack Champagne 
Date: Thu, 24 Sep 2026 00:34:45 -0400
Subject: [PATCH 3/6] fix(app-bundle): resolve overlay typecheck drift for
 v1.18.30
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The overlay_typecheck_gate (#1348, fail-closed) caught the real i18n API
drift the build:binary lane missed. v1.18.30 reworked the i18n context
(UiI18n gained plural(key,count,params); desktop menu items moved from
label to labelKey resolved via i18n; DESKTOP_NATIVE_ENGLISH spread into
the dict). Reconcile every drifted overlay file (three-way merge, taking
upstream + re-applying amicode customizations):

- language.tsx: take upstream (plural/layoutLocale/direction); keep our
  'en' default-locale (only en.ts is branded)
- app.tsx: UiI18nBridge passes layoutLocale + plural through
- en.ts: import + spread DESKTOP_NATIVE_ENGLISH; override the app-name
  desktop.menu/recovery strings to 'Amicode'
- desktop-menu.ts: take upstream labelKey shape; keep our action-only zoom
  (no native role, macOS accelerators)
- windows-app-menu.tsx: take upstream labelKey resolution; keep rounded-sm
  + 'Amicode' group label
- helpers.ts: take upstream compareSessionTime; keep our sessionListDirectories
- prompt-input.tsx / image-attachments.tsx / v2/prompt-input: fileLabel prop
  + typeLabel 3-arg
- message-part.tsx: AnimatedCountList {key,count} shape + typeLabel 3-arg
- tool-count-label.tsx: take upstream plural-key AnimatedCountLabel; keep
  splitCountLabel export for our added test
- markdown-shiki.worker.ts: DELETE — orphaned by the upstream marked
  pipeline rewrite (nothing imports it; CM6 theme sync via shiki-theme-state
  is a separate, intact path)

Verified: overlay typecheck clean across all 7 packages, engine typecheck
gate clean, build:binary + build:app green, drift-gate PASS.
---
 packages/app-bundle/manifest.json             |  36 ++-
 .../overlay/packages/app/src/app.tsx          |   8 +-
 .../app/src/components/prompt-input.tsx       |   3 +-
 .../prompt-input/image-attachments.tsx        |   3 +-
 .../app/src/components/windows-app-menu.tsx   |  12 +-
 .../packages/app/src/context/language.tsx     | 239 +++++++++---------
 .../overlay/packages/app/src/desktop-menu.ts  | 185 ++++++++++----
 .../overlay/packages/app/src/i18n/en.ts       |  11 +
 .../packages/app/src/pages/layout/helpers.ts  |  84 +++---
 .../src/components/markdown-shiki.worker.ts   | 118 ---------
 .../src/components/message-part.tsx           |  14 +-
 .../src/components/tool-count-label.tsx       |  35 ++-
 .../src/v2/components/prompt-input/index.tsx  |   4 +-
 packages/extension/opencode.lock.json         |   2 +-
 14 files changed, 364 insertions(+), 390 deletions(-)
 delete mode 100644 packages/app-bundle/overlay/packages/session-ui/src/components/markdown-shiki.worker.ts

diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index 1007d2443..41f3e5296 100644
--- a/packages/app-bundle/manifest.json
+++ b/packages/app-bundle/manifest.json
@@ -24,9 +24,9 @@
     "packages/app/public/amico.svg": "7c64b145bdd063bacd53318336a423f0654ec702de2b4e0c4a4a384694052804",
     "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6",
     "packages/app/src/app-desktop-commands.test.ts": "1d2057f238dc14e4407ff5c4e53afe091784790bc0fd0dbfde0eacd91e2bd968",
-    "packages/app/src/app.tsx": "81fee81c4a1fe27ef59c54dc0d33fc2d531433dcd4f67c1de388bb8d6dd1e6d4",
+    "packages/app/src/app.tsx": "07b292f88de59fad23b0e7564e0c536214917b65b50326552894925a9c0e17d1",
     "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910",
-    "packages/app/src/desktop-menu.ts": "55481604f4fd5dcb74b0678347076c42fda7f928590d90bc2c3d1633bea8bc36",
+    "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24",
     "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c",
     "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9",
     "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1",
@@ -116,7 +116,7 @@
     "packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8",
     "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da",
     "packages/app/src/components/prompt-input-v2.tsx": "5ab46e41bda4fa11e99f06c37df76c1fd3cce061c6ba7a9f85bee369a965b98a",
-    "packages/app/src/components/prompt-input.tsx": "6baf3628f66875ca5d0e259221a6261e71cdcbb1f5e511a8e6dfea77874f68a4",
+    "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4",
     "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294",
     "packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd",
     "packages/app/src/components/report-bug-button.stories.tsx": "82aed22bd3fcca448841205dee759ca0f0de92562a99d07c696eee5bd29e807b",
@@ -147,12 +147,12 @@
     "packages/app/src/components/vault-browser-model.ts": "d9ef6ff588a96ae79b13c3ae0c2a442a42b59021dd501b3a17bd810dda6f7b20",
     "packages/app/src/components/vault-browser.tsx": "509efa874d69bcd07ef2495a025bc8de2b2fa582736003ff709b4ca76096274b",
     "packages/app/src/components/vault-panel.tsx": "1d9f06f42b94ffb94a28bfd6d2693a36c75ceb969bcd434081b4cd2fbf99665c",
-    "packages/app/src/components/windows-app-menu.tsx": "460669a129e9be61ab2d5f51fe6ecad73579a395295d5f724f235b988cdc667e",
+    "packages/app/src/components/windows-app-menu.tsx": "262d085facace692084d79763a6bd8eba6c5ceb53b952a134e12c7e8a8a12946",
     "packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690",
     "packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172",
     "packages/app/src/context/directory-sync.test.ts": "c03c7bcd95ac47b3de5e1e9719b6eccdb3569063026fe993b448453c818c7b48",
     "packages/app/src/context/directory-sync.ts": "8515e0f8a5bdf0bf6243a0c2dfe64f234f5292074464e38c68da8287235acc3e",
-    "packages/app/src/context/language.tsx": "cb545e04c128bf981b2b72ac407f9220cf9e80337fe8c87482ffe85407e0cf9a",
+    "packages/app/src/context/language.tsx": "0f7b1cbd33071f293c15b66e3a900dfd07347f9eb93d8cf61df0fac3bc4effc5",
     "packages/app/src/context/layout-side-panel-tabs.test.ts": "47425bbed0ada18d3fcb67a76b63de3b9e1f827cd63a61ffe4eb45430fc5814e",
     "packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76",
     "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c",
@@ -188,7 +188,7 @@
     "packages/app/src/i18n/compaction-failure.test.ts": "a4a3765beb238a2c157c07c9c8ede8418b9febb814d8d1e3a5b2d8868cbd80d9",
     "packages/app/src/i18n/da.ts": "db685cb94f22ace194850efe9bdc02c530777a4583dc9eb9d00b97002b0a0968",
     "packages/app/src/i18n/de.ts": "c440ad7543321cae1425e0c2fad92ff73963f21672fbf31176831933573bf103",
-    "packages/app/src/i18n/en.ts": "9befd8ce48336cdc981d7094d0a7ef53cb6634f1b9049d2025416cf22b05ceb5",
+    "packages/app/src/i18n/en.ts": "01edcfcc7bea550ef0fc98b49ea00c59ce3c320767fb5ab633ca9a4ba59b0e2b",
     "packages/app/src/i18n/es.ts": "8cdec3592cddf0b91d7fd59002c81c4e8973c5bf6573e065df64d48c42e1802f",
     "packages/app/src/i18n/fr.ts": "b9f646352189054b6918bfbac670579e76418c3148eb7e5afca9d71d97e7f2b7",
     "packages/app/src/i18n/ja.ts": "27ffc41a0fd8dc4b2e814a45c55fad4726ff1c7a85c884cf6af95f8397f17cbf",
@@ -389,7 +389,6 @@
     "packages/session-ui/src/components/line-comment-styles.ts": "2077836c13bd3c3f1c39c67722119e448a2dfa123377f94bfd3676634e955d00",
     "packages/session-ui/src/components/markdown-file-refs.test.ts": "e2b1bed33629d3ab53c521f33266814088ea4122b6e64d6ab294eacc4cf15070",
     "packages/session-ui/src/components/markdown-file-refs.ts": "b75bddeb565dc0163635fab8f84bdd26723742f9f82d06b6fd5991777b311032",
-    "packages/session-ui/src/components/markdown-shiki.worker.ts": "2748ddbfcd625bf696b27d9b64895e64ee47a3f316c88930010e260b4ba37f4b",
     "packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83",
     "packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399",
     "packages/session-ui/src/components/markdown.css": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165",
@@ -404,14 +403,14 @@
     "packages/session-ui/src/components/message-part-user.ts": "1f2c8cb4efff5274863a133f369a7397cac804678e953740f356ae03b71d475c",
     "packages/session-ui/src/components/message-part.css": "ae592deb33739738f6a7fc6a3460b7146803bdd05cbbf46f1cfd2c5cec421392",
     "packages/session-ui/src/components/message-part.test.ts": "7c66430837fa3d2607657e57e35720ae68dc5e84662e12826713a938301eeb7b",
-    "packages/session-ui/src/components/message-part.tsx": "89c6d920516e3c991b81ace10b14cc75df1dcf130723e87af6bec448688a7612",
+    "packages/session-ui/src/components/message-part.tsx": "dda5104168d8b841c5f96bb27737d49375b19f68e64f1f6a7c9e7776ae332164",
     "packages/session-ui/src/components/session-retry.tsx": "2cb1513d73275a269894f601e8c1d29bc790b14927134fe36177f2b371888721",
     "packages/session-ui/src/components/session-review.css": "ec8c5b359dd4b7ff80d7fad8906359678adade9ce62c9d74aedc000b646db341",
     "packages/session-ui/src/components/session-review.tsx": "d2015c9f7419279f08e84523439a0b73e83bd5d06587ad56b85ea53b66ecaee4",
     "packages/session-ui/src/components/session-turn.css": "792915f3373134eadf3cd51e1d4bfa88ec347e6b037b6caf60b639ec5e74fbd8",
     "packages/session-ui/src/components/session-turn.tsx": "9cbc0d7a35f42e32dad53ace7b3c7d92623fa2d3108edb49e0b7bc868f5c1966",
     "packages/session-ui/src/components/tool-count-label.test.ts": "c62bbe2c662f6740794d25bd7c33fb3b9b46394a1b41d01d6568a8a2ee2b4e75",
-    "packages/session-ui/src/components/tool-count-label.tsx": "6c0f42d2c3fdc8c63bff3ca5a51aaa6ebcac9dc773f971884cbc2696246502a8",
+    "packages/session-ui/src/components/tool-count-label.tsx": "296ca8caf5e3d9a131a968cf1c13b386213eb5620b16bdc0f72cf9246fcafb35",
     "packages/session-ui/src/components/tool-error-card.tsx": "e27b89836b7c709120ac15fbffd2200038944058ea3506e61e5897ffb3537503",
     "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a",
     "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee",
@@ -618,7 +617,7 @@
     "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "15f76dca1a25d024128718e4099220b79ca029263410aab55502cf9caf9b2914",
     "packages/app/src/components/prompt-input/clipboard-bridge.ts": "4b5a1edf59d4c85268d6ff72aa9f9d66f82d4fdb887121fb978c09caf4c5be65",
     "packages/app/src/components/prompt-input/context-items.tsx": "027b02e394ccd14849052bf3fcacdae1e090c71d164fa8955addfe3f7abf1758",
-    "packages/app/src/components/prompt-input/image-attachments.tsx": "30cdea4285884b4c257670b57ade4266f5861e8a303d3355a591f302ed4bf965",
+    "packages/app/src/components/prompt-input/image-attachments.tsx": "aa451ed6aa61a68ba0e8dacd8c45137f84c62566797a5242b75a89487ef0e9bf",
     "packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3",
     "packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d",
     "packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d",
@@ -674,7 +673,7 @@
     "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8",
     "packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c",
     "packages/app/src/pages/layout/helpers.test.ts": "3a77cfe018ce2867cccded2c726f556e53a9895a77baa944a4878714b9b2b59e",
-    "packages/app/src/pages/layout/helpers.ts": "ba476894857aefd2172e3d31fa1aaf52ca030183d3fbf180050bd3b10fe4b524",
+    "packages/app/src/pages/layout/helpers.ts": "b9b9bd551833390d9dcb7e2bc56de77abdef770d7d786f732930ed2bb6b8f4d6",
     "packages/app/src/pages/layout/notification-toasts.test.ts": "6a95bd3dccb03be894b5d2adb4bc1f7531a9ec704e4d0eae1eba4ec89bbc9846",
     "packages/app/src/pages/layout/notification-toasts.ts": "11256e25f2bcc20d3520e81aa50b53db2a7f8dea8047b17e99deb1f9f061e43d",
     "packages/app/src/pages/layout/project-avatar-state.ts": "7aadb235d27dc45799411c724f0fd1282af82d091cbcefb0344deb7c5d0659d4",
@@ -848,7 +847,7 @@
     "packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17",
     "packages/sdk/js/src/v2/gen/types.gen.ts": "0c23b262085d1e14b89bd03292a380c9ec8b52e66c4308304a05173362b79339",
     "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea",
-    "packages/session-ui/src/v2/components/prompt-input/index.tsx": "6cbd3a1e3a4fd789284526e5981c6fd2d4952f3926c5a1be0609bb6832453997",
+    "packages/session-ui/src/v2/components/prompt-input/index.tsx": "194b1c6d0c99000579df78b47c7ff72fe34f62a92ecd21b2a9377133d6d27bb9",
     "packages/opencode/src/server/routes/instance/httpapi/server.ts": "85c200797fed41c0cd08e7aa699ce13aeb8a3d23aa2b9eddc4d72e72b8b5082c",
     "packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "944a0aee215a917e867fd9482f3c2bd40f49082bd3174c8a7d967ca40e34ec15",
     "packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "ed165131c62eeb29375db39edf89db8f87488e24ef99fc6f2cf19705ba35751a",
@@ -874,11 +873,11 @@
     "packages/ui/src/v2/components/text-shimmer-v2.tsx"
   ],
   "exceptions": [],
-  "extracted_at": "2026-09-23T22:20:19.214Z",
+  "extracted_at": "2026-09-24T04:46:38.771Z",
   "per_package": {
     "packages/app": {
-      "A": 187,
-      "M": 156,
+      "A": 186,
+      "M": 157,
       "D": 0
     },
     "packages/core": {
@@ -913,7 +912,7 @@
     },
     "packages/session-ui": {
       "A": 42,
-      "M": 32,
+      "M": 31,
       "D": 0
     },
     "packages/ui": {
@@ -923,7 +922,7 @@
     }
   },
   "counts": {
-    "overlay_total": 852,
+    "overlay_total": 851,
     "deletions": 7,
     "server_coupled": 0
   },
@@ -1309,7 +1308,6 @@
     "packages/session-ui/src/components/line-comment-styles.ts": "M",
     "packages/session-ui/src/components/markdown-file-refs.test.ts": "A",
     "packages/session-ui/src/components/markdown-file-refs.ts": "A",
-    "packages/session-ui/src/components/markdown-shiki.worker.ts": "M",
     "packages/session-ui/src/components/markdown-stream.test.ts": "M",
     "packages/session-ui/src/components/markdown-stream.ts": "M",
     "packages/session-ui/src/components/markdown.css": "M",
@@ -1587,7 +1585,7 @@
     "packages/app/src/context/global-sync/bootstrap.test.ts": "A",
     "packages/app/src/context/global-sync/child-store.ts": "A",
     "packages/app/src/context/global-sync/event-reducer.test.ts": "M",
-    "packages/app/src/context/global-sync/event-reducer.ts": "A",
+    "packages/app/src/context/global-sync/event-reducer.ts": "M",
     "packages/app/src/context/global-sync/session-cache.test.ts": "A",
     "packages/app/src/context/global-sync/session-cache.ts": "M",
     "packages/app/src/context/global-sync/types.ts": "M",
diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx
index 9b4d173c3..4168cd0af 100644
--- a/packages/app-bundle/overlay/packages/app/src/app.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/app.tsx
@@ -324,7 +324,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
 
 function UiI18nBridge(props: ParentProps) {
   const language = useLanguage()
-  return {props.children}
+  return (
+    
+      {props.children}
+    
+  )
 }
 
 function LayoutCompatibility(props: ParentProps) {
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx
index d75cded17..dc8a124f6 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx
@@ -80,7 +80,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
 import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
-import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
+import { promptPlaceholder } from "./prompt-input/placeholder"
 import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { showToast } from "@/utils/toast"
 import { hiddenProjectWorktree } from "@/utils/amicode-hidden-project"
@@ -1499,6 +1499,7 @@ export const PromptInput: Component = (props) => {
           }
           onRemove={removeAttachment}
           removeLabel={language.t("prompt.attachment.remove")}
+          fileLabel={language.t("ui.common.file")}
           newLayoutDesigns={false}
         />
         
void onRemove: (id: string) => void removeLabel: string + fileLabel: string newLayoutDesigns: boolean comments?: PromptCommentItem[] commentActive?: (item: PromptCommentItem) => boolean @@ -94,7 +95,7 @@ export const PromptImageAttachments: Component = (p } > - {typeLabel(attachment.filename, attachment.mime)} + {typeLabel(attachment.filename, attachment.mime, props.fileLabel)} } diff --git a/packages/app-bundle/overlay/packages/app/src/components/windows-app-menu.tsx b/packages/app-bundle/overlay/packages/app/src/components/windows-app-menu.tsx index 62f9f0316..80bc4ff26 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/windows-app-menu.tsx @@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useCommand } from "@/context/command" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" export function WindowsAppMenu(props: { command: ReturnType @@ -15,6 +16,7 @@ export function WindowsAppMenu(props: { variant?: "legacy" | "v2" }) { let lastFocused: HTMLElement | undefined + const language = useLanguage() const rememberFocus = () => { const active = document.activeElement @@ -58,7 +60,7 @@ export function WindowsAppMenu(props: { variant="ghost-muted" size="large" icon={} - aria-label="OpenCode menu" + aria-label={language.t("desktop.menu.ariaLabel")} onPointerDown={rememberFocus} onKeyDown={rememberFocus} /> @@ -69,7 +71,7 @@ export function WindowsAppMenu(props: { icon="menu" variant="ghost" class="titlebar-icon rounded-md shrink-0" - aria-label="OpenCode menu" + aria-label={language.t("desktop.menu.ariaLabel")} onPointerDown={rememberFocus} onKeyDown={rememberFocus} /> @@ -77,9 +79,9 @@ export function WindowsAppMenu(props: { - OpenCode + Amicode {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => ( - + {menu.items ?.filter((entry) => desktopMenuVisible(entry, "windows")) .map((entry) => @@ -87,7 +89,7 @@ export function WindowsAppMenu(props: { ) : ( runEntry(entry)} diff --git a/packages/app-bundle/overlay/packages/app/src/context/language.tsx b/packages/app-bundle/overlay/packages/app/src/context/language.tsx index 1b0410dbc..ad3dfe1c1 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/language.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/language.tsx @@ -2,100 +2,46 @@ import * as i18n from "@solid-primitives/i18n" import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" +import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" - -export type Locale = - | "en" - | "zh" - | "zht" - | "ko" - | "de" - | "es" - | "fr" - | "da" - | "ja" - | "pl" - | "ru" - | "uk" - | "ar" - | "no" - | "br" - | "th" - | "bs" - | "tr" +import { + createDesktopNativeBundle, + detectDesktopNativeLocale, + DESKTOP_NATIVE_ENGLISH, + DESKTOP_NATIVE_LABELS, + DESKTOP_NATIVE_LOCALES, + DESKTOP_NATIVE_LOCALE_TAGS, + type DesktopNativeBundle, + type DesktopNativeLocale, +} from "@/i18n/desktop-native" + +export type Locale = DesktopNativeLocale +export type Direction = "ltr" | "rtl" + +const RTL_LOCALES: ReadonlySet = new Set(["ar", "ur", "pa", "fa", "dv"]) + +function localeDirection(locale: Locale): Direction { + return RTL_LOCALES.has(locale) ? "rtl" : "ltr" +} type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type PluralKey = + | UiI18nPluralKey + | "session.question.pending" + | "session.followupDock.summary" + | "session.revertDock.summary" type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` } -const LOCALES: readonly Locale[] = [ - "en", - "zh", - "zht", - "ko", - "de", - "es", - "fr", - "da", - "ja", - "pl", - "ru", - "uk", - "bs", - "ar", - "no", - "br", - "th", - "tr", -] - -const INTL: Record = { - en: "en", - zh: "zh-Hans", - zht: "zh-Hant", - ko: "ko", - de: "de", - es: "es", - fr: "fr", - da: "da", - ja: "ja", - pl: "pl", - ru: "ru", - uk: "uk", - ar: "ar", - no: "nb-NO", - br: "pt-BR", - th: "th", - bs: "bs", - tr: "tr", -} +const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES -const LABEL_KEY: Record = { - en: "language.en", - zh: "language.zh", - zht: "language.zht", - ko: "language.ko", - de: "language.de", - es: "language.es", - fr: "language.fr", - da: "language.da", - ja: "language.ja", - pl: "language.pl", - ru: "language.ru", - uk: "language.uk", - ar: "language.ar", - no: "language.no", - br: "language.br", - th: "language.th", - bs: "language.bs", - tr: "language.tr", -} +const INTL = DESKTOP_NATIVE_LOCALE_TAGS const base = i18n.flatten({ ...en, ...uiEn }) const dicts = new Map([["en", base]]) @@ -121,6 +67,50 @@ const loaders: Record, () => Promise> = { th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), + hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")), + nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")), + id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")), + vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")), + it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")), + ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")), + pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")), + az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")), + fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")), + sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")), + am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")), + bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")), + bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")), + ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")), + cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")), + dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")), + dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")), + el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")), + et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")), + fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")), + fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")), + hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")), + hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")), + hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")), + is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")), + ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")), + km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")), + lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")), + lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")), + lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")), + mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")), + mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")), + ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")), + my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")), + ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")), + ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")), + si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")), + sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")), + sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")), + sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")), + sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")), + tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")), + tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")), + uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")), } function loadDict(locale: Locale) { @@ -138,42 +128,9 @@ export function loadLocaleDict(locale: Locale) { return loadDict(locale).then(() => undefined) } -const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ - { locale: "en", match: (language) => language.startsWith("en") }, - { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, - { locale: "zh", match: (language) => language.startsWith("zh") }, - { locale: "ko", match: (language) => language.startsWith("ko") }, - { locale: "de", match: (language) => language.startsWith("de") }, - { locale: "es", match: (language) => language.startsWith("es") }, - { locale: "fr", match: (language) => language.startsWith("fr") }, - { locale: "da", match: (language) => language.startsWith("da") }, - { locale: "ja", match: (language) => language.startsWith("ja") }, - { locale: "pl", match: (language) => language.startsWith("pl") }, - { locale: "ru", match: (language) => language.startsWith("ru") }, - { locale: "uk", match: (language) => language.startsWith("uk") }, - { locale: "ar", match: (language) => language.startsWith("ar") }, - { - locale: "no", - match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"), - }, - { locale: "br", match: (language) => language.startsWith("pt") }, - { locale: "th", match: (language) => language.startsWith("th") }, - { locale: "bs", match: (language) => language.startsWith("bs") }, - { locale: "tr", match: (language) => language.startsWith("tr") }, -] - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" - - const languages = navigator.languages?.length ? navigator.languages : [navigator.language] - for (const language of languages) { - if (!language) continue - const normalized = language.toLowerCase() - const match = localeMatchers.find((entry) => entry.match(normalized)) - if (match) return match.locale - } - - return "en" + return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language]) } export function normalizeLocale(value: string): Locale { @@ -198,12 +155,22 @@ function readStoredLocale() { // REVERT CONDITION: restore `?? detectLocale()` here and in init() below once // the non-English locales are branded. const warm = readStoredLocale() ?? "en" -if (warm !== "en") void loadDict(warm) +const initialLocale = + warm === "en" + ? Promise.resolve(warm) + : loadDict(warm).then( + () => warm, + () => "en" as const, + ) + +export function loadInitialLocale() { + return initialLocale +} export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", gate: false, - init: (props: { locale?: Locale }) => { + init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => { const initial = props.locale ?? readStoredLocale() ?? "en" // amicode: see comment above `warm` const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), @@ -214,6 +181,13 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont const locale = createMemo(() => normalizeLocale(store.locale)) const intl = createMemo(() => INTL[locale()]) + const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined }) + const direction = createMemo(() => layout.direction ?? localeDirection(locale())) + const layoutLocale = createMemo(() => { + if (!layout.direction) return intl() + // Kobalte derives menu direction from locale rather than accepting a direction override. + return layout.direction === "rtl" ? "ar" : "en" + }) const [dict] = createResource(locale, loadDict, { initialValue: dicts.get(initial) ?? base, @@ -224,24 +198,49 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont params?: Record, ) => string - const label = (value: Locale) => t(LABEL_KEY[value]) + const plural = (key: PluralKey, count: number, params?: Record) => { + const category = pluralCategory(intl(), count) + const current = (dict.loading ? base : (dict() ?? base)) as Record + const candidate = `${key}.${category}` + const fallback = `${key}.other` + return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count }) + } + + const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value] createEffect(() => { if (typeof document !== "object") return - document.documentElement.lang = locale() - document.cookie = cookie(locale()) + const value = locale() + document.documentElement.lang = intl() + document.documentElement.dir = direction() + document.cookie = cookie(value) + }) + + createEffect(() => { + if (!props.onNativeTranslations || dict.loading) return + const current = dict() + if (!current) return + props.onNativeTranslations( + createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]), + ) }) return { ready, locale, intl, + direction, + layoutLocale, locales: LOCALES, label, t, + plural, setLocale(next: Locale) { setStore("locale", normalizeLocale(next)) }, + setDirection(next: Direction) { + setLayout("direction", next === localeDirection(locale()) ? undefined : next) + }, } }, }) diff --git a/packages/app-bundle/overlay/packages/app/src/desktop-menu.ts b/packages/app-bundle/overlay/packages/app/src/desktop-menu.ts index f0bef79b3..d74c49c20 100644 --- a/packages/app-bundle/overlay/packages/app/src/desktop-menu.ts +++ b/packages/app-bundle/overlay/packages/app/src/desktop-menu.ts @@ -1,3 +1,5 @@ +import type { DesktopNativeKey } from "./i18n/desktop-native" + export type DesktopMenuPlatform = "macos" | "windows" export type DesktopMenuAction = @@ -44,7 +46,7 @@ export type DesktopMenuRole = export type DesktopMenuItem = { type: "item" - label?: string + labelKey?: DesktopNativeKey command?: string action?: DesktopMenuAction role?: DesktopMenuRole @@ -63,7 +65,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator export type DesktopMenu = { id: string - label: string + labelKey: DesktopNativeKey role?: DesktopMenuRole items?: DesktopMenuEntry[] platforms?: DesktopMenuPlatform[] @@ -72,15 +74,20 @@ export type DesktopMenu = { export const DESKTOP_MENU: DesktopMenu[] = [ { id: "app", - label: "Amicode", + labelKey: "desktop.menu.app", platforms: ["macos"], items: [ { type: "item", role: "about" }, - { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" }, - { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, - { type: "item", label: "Reload Webview", action: "view.reload" }, - { type: "item", label: "Restart", action: "app.relaunch" }, - { type: "item", label: "Export Logs...", command: "logs.export" }, + { + type: "item", + labelKey: "desktop.menu.checkForUpdates", + action: "app.checkForUpdates", + enabled: "updater", + }, + { type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, + { type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" }, + { type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" }, + { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" }, { type: "separator" }, { type: "item", role: "hide" }, { type: "item", role: "hideOthers" }, @@ -91,46 +98,81 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { id: "file", - label: "File", + labelKey: "desktop.menu.file", items: [ { type: "item", - label: "New Session", + labelKey: "desktop.menu.newSession", command: "session.new", accelerator: { macos: "Shift+Cmd+S" }, }, - { type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } }, { type: "item", - label: "Settings", + labelKey: "desktop.menu.openProject", + command: "project.open", + accelerator: { macos: "Cmd+O" }, + }, + { + type: "item", + labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { windows: "Ctrl+," }, platforms: ["windows"], }, { type: "item", - label: "New Window", + labelKey: "desktop.menu.newWindow", action: "window.new", accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, }, { type: "separator" }, - { type: "item", label: "Close Window", action: "window.close", role: "close" }, + { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" }, ], }, { id: "edit", - label: "Edit", + labelKey: "desktop.menu.edit", items: [ - { type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } }, - { type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } }, + { + type: "item", + labelKey: "desktop.menu.undo", + action: "edit.undo", + role: "undo", + accelerator: { windows: "Ctrl+Z" }, + }, + { + type: "item", + labelKey: "desktop.menu.redo", + action: "edit.redo", + role: "redo", + accelerator: { windows: "Ctrl+Y" }, + }, { type: "separator" }, - { type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } }, - { type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } }, - { type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } }, - { type: "item", label: "Delete", action: "edit.delete" }, { type: "item", - label: "Select All", + labelKey: "desktop.menu.cut", + action: "edit.cut", + role: "cut", + accelerator: { windows: "Ctrl+X" }, + }, + { + type: "item", + labelKey: "desktop.menu.copy", + action: "edit.copy", + role: "copy", + accelerator: { windows: "Ctrl+C" }, + }, + { + type: "item", + labelKey: "desktop.menu.paste", + action: "edit.paste", + role: "paste", + accelerator: { windows: "Ctrl+V" }, + }, + { type: "item", labelKey: "desktop.menu.delete", action: "edit.delete" }, + { + type: "item", + labelKey: "desktop.menu.selectAll", action: "edit.selectAll", role: "selectAll", accelerator: { windows: "Ctrl+A" }, @@ -139,51 +181,86 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { id: "view", - label: "View", + labelKey: "desktop.menu.view", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, - { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, - { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, + { type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" }, + { + type: "item", + labelKey: "desktop.menu.toggleTerminal", + command: "terminal.toggle", + accelerator: { macos: "Ctrl+`" }, + }, + { type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" }, { type: "separator" }, - { type: "item", label: "Reload", action: "view.reload", role: "reload" }, - { type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" }, + { type: "item", labelKey: "desktop.menu.reload", action: "view.reload", role: "reload" }, + { + type: "item", + labelKey: "desktop.menu.toggleDeveloperTools", + action: "view.toggleDevTools", + role: "toggleDevTools", + }, { type: "separator" }, - // action-only, no native role: on macOS a role becomes Electron's own - // zoom (multiplicative levels, no zoom-factor-changed event) while the - // renderer keydown ALSO handles Cmd+=/-/0 — two systems fighting over - // one chord, with the titlebar/terminal reading a stale factor. The - // action path is the single implementation on every platform. { type: "item", - label: "Actual Size", + labelKey: "desktop.menu.actualSize", action: "view.resetZoom", + // action-only, no native role: on macOS a role becomes Electron's own + // zoom (multiplicative levels, no zoom-factor-changed event) while the + // renderer keydown ALSO handles Cmd+=/-/0 — two systems fighting over + // one chord, with the titlebar/terminal reading a stale factor. The + // action path is the single implementation on every platform. accelerator: { macos: "Cmd+0", windows: "Ctrl+0" }, }, - { type: "item", label: "Zoom In", action: "view.zoomIn", accelerator: { macos: "Cmd+=", windows: "Ctrl++" } }, - { type: "item", label: "Zoom Out", action: "view.zoomOut", accelerator: { macos: "Cmd+-", windows: "Ctrl+-" } }, + { + type: "item", + labelKey: "desktop.menu.zoomIn", + action: "view.zoomIn", + accelerator: { macos: "Cmd+=", windows: "Ctrl++" }, + }, + { + type: "item", + labelKey: "desktop.menu.zoomOut", + action: "view.zoomOut", + accelerator: { macos: "Cmd+-", windows: "Ctrl+-" }, + }, { type: "separator" }, - { type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" }, + { + type: "item", + labelKey: "desktop.menu.toggleFullScreen", + action: "view.toggleFullscreen", + role: "togglefullscreen", + }, ], }, { id: "go", - label: "Go", + labelKey: "desktop.menu.go", items: [ - { type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, - { type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, + { type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, + { type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, { type: "separator" }, - { type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } }, - { type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } }, + { + type: "item", + labelKey: "desktop.menu.previousSession", + command: "session.previous", + accelerator: { macos: "Option+Up" }, + }, + { + type: "item", + labelKey: "desktop.menu.nextSession", + command: "session.next", + accelerator: { macos: "Option+Down" }, + }, { type: "separator" }, { type: "item", - label: "Previous Project", + labelKey: "desktop.menu.previousProject", command: "project.previous", accelerator: { macos: "Cmd+Option+Up" }, }, { type: "item", - label: "Next Project", + labelKey: "desktop.menu.nextProject", command: "project.next", accelerator: { macos: "Cmd+Option+Down" }, }, @@ -191,31 +268,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { id: "window", - label: "Window", + labelKey: "desktop.menu.window", role: "windowMenu", items: [ - { type: "item", label: "Minimize", action: "window.minimize" }, - { type: "item", label: "Maximize", action: "window.toggleMaximize" }, + { type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" }, + { type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" }, { type: "separator" }, - { type: "item", label: "Close Window", action: "window.close" }, + { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" }, ], }, { id: "help", - label: "Help", + labelKey: "desktop.menu.help", items: [ - { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, - { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, - { type: "item", label: "Export Logs...", command: "logs.export" }, + { type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" }, + { type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" }, + { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" }, { type: "separator" }, { type: "item", - label: "Share Feedback", + labelKey: "desktop.menu.shareFeedback", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml", }, { type: "item", - label: "Report a Bug", + labelKey: "desktop.menu.reportBug", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml", }, ], diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts index f1d7fcfad..053d29c0f 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts @@ -1,4 +1,15 @@ +import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native" + export const dict = { + ...DESKTOP_NATIVE_ENGLISH, + // amicode: rebrand the desktop-native menu strings (DESKTOP_NATIVE_ENGLISH + // ships them as "OpenCode"). Spread first, override after so ours win. + "desktop.menu.app": "Amicode", + "desktop.menu.documentation": "Amicode Documentation", + "desktop.menu.ariaLabel": "Amicode menu", + "desktop.recovery.loadFailed": "Amicode failed to load", + "desktop.recovery.terminated": "Amicode window terminated unexpectedly", + "desktop.recovery.unresponsive": "Amicode is not responding", "command.category.suggested": "Suggested", "command.category.view": "View", "command.category.project": "Project", diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts b/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts index e99324465..c18966aa4 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/helpers.ts @@ -9,18 +9,10 @@ type SessionStore = { path: { directory: string } } -function sortSessions(now: number) { - const oneMinuteAgo = now - 60 * 1000 - return (a: Session, b: Session) => { - const aUpdated = a.time.updated ?? a.time.created - const bUpdated = b.time.updated ?? b.time.created - const aRecent = aUpdated > oneMinuteAgo - const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - if (aRecent && !bRecent) return -1 - if (!aRecent && bRecent) return 1 - return bUpdated - aUpdated - } +export function compareSessionTime(a: Session, b: Session) { + const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created) + if (updated !== 0) return updated + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 } const isRootVisibleSession = (session: Session, directory: string) => @@ -29,10 +21,10 @@ const isRootVisibleSession = (session: Session, directory: string) => export const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) -export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) +export const sortedRootSessions = (store: SessionStore, _now: number) => roots(store).sort(compareSessionTime) -export const latestRootSession = (stores: SessionStore[], now: number) => - stores.flatMap(roots).sort(sortSessions(now))[0] +export const latestRootSession = (stores: SessionStore[], _now: number) => + stores.flatMap(roots).sort(compareSessionTime)[0] export function hasProjectPermissions( request: Record | undefined, @@ -57,37 +49,6 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri export const displayName = (project: { name?: string; worktree: string }) => project.name || getFilename(project.worktree) || project.worktree -/** - * Directories whose sessions the list surfaces (Sessions dropdown, home list) - * — always the union of opened projects (first) and server-registered projects - * (deduplicated, appended). Every historical project the server knows about - * contributes sessions, not just the ones currently open. Session listing only; - * this must NOT feed the project switcher, or closing a server-registered - * project becomes impossible (amicode#839, replacing the fallback-only behavior - * from amicode#288). - */ -export function sessionListDirectories( - opened: { worktree: string; sandboxes?: string[] }[], - serverProjects: { worktree: string; sandboxes?: string[] }[], -): string[] { - const seen = new Set() - const result: string[] = [] - const add = (d: string) => { - if (!d || seen.has(d)) return - seen.add(d) - result.push(d) - } - for (const p of opened) { - add(p.worktree) - for (const s of p.sandboxes ?? []) add(s) - } - for (const p of serverProjects) { - add(p.worktree) - for (const s of p.sandboxes ?? []) add(s) - } - return result -} - export function toggleHomeProjectSelection( current: HomeProjectSelection | undefined, server: ServerConnection.Key, @@ -179,3 +140,34 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted return [...result, ...live.values()] } + +/** + * Directories whose sessions the list surfaces (Sessions dropdown, home list) + * — always the union of opened projects (first) and server-registered projects + * (deduplicated, appended). Every historical project the server knows about + * contributes sessions, not just the ones currently open. Session listing only; + * this must NOT feed the project switcher, or closing a server-registered + * project becomes impossible (amicode#839, replacing the fallback-only behavior + * from amicode#288). + */ +export function sessionListDirectories( + opened: { worktree: string; sandboxes?: string[] }[], + serverProjects: { worktree: string; sandboxes?: string[] }[], +): string[] { + const seen = new Set() + const result: string[] = [] + const add = (d: string) => { + if (!d || seen.has(d)) return + seen.add(d) + result.push(d) + } + for (const p of opened) { + add(p.worktree) + for (const s of p.sandboxes ?? []) add(s) + } + for (const p of serverProjects) { + add(p.worktree) + for (const s of p.sandboxes ?? []) add(s) + } + return result +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-shiki.worker.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-shiki.worker.ts deleted file mode 100644 index 6e8741480..000000000 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-shiki.worker.ts +++ /dev/null @@ -1,118 +0,0 @@ -/// - -import { ShikiStreamTokenizer } from "@shikijs/stream" -import { - bundledLanguages, - createHighlighter, - getTokenStyleObject, - stringifyTokenStyle, - type BundledLanguage, - type ThemedToken, -} from "shiki" -import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol" -import { createLatestWorkerQueue } from "./markdown-worker-queue" - -type Stream = { - language: string - source: string - tokenizer: ShikiStreamTokenizer -} - -const streams = new Map() -let highlighter: ReturnType | undefined -let currentThemeName = "OpenCode" -const queue = createLatestWorkerQueue>({ - run: highlight, - supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }), - dispose: (key) => void streams.delete(key), -}) - -self.onmessage = (event: MessageEvent) => { - if (event.data.type === "init") { - highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] }) - return - } - if (event.data.type === "dispose") { - queue.dispose(event.data.key) - return - } - // Theme update from the extension bridge (via shiki-theme-state) - if ((event.data as any).type === "theme-update") { - const msg = event.data as any - if (typeof msg.theme === "object" && highlighter) { - highlighter.then((instance) => { - if (instance) instance.loadTheme(msg.theme) - }) - } - if (typeof msg.name === "string") currentThemeName = msg.name - // Clear all streams so they pick up the new theme on next highlight - streams.clear() - return - } - - queue.highlight(event.data) -} - -async function highlight(request: Extract) { - try { - const instance = await highlighter - if (!instance) throw new Error("Shiki worker is not initialized") - const language = request.language in bundledLanguages ? request.language : "text" - if (!instance.getLoadedLanguages().includes(language)) - await instance.loadLanguage(bundledLanguages[language as BundledLanguage]) - - if (request.complete) { - const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: currentThemeName }) - streams.delete(request.key) - post({ - type: "highlight", - id: request.id, - key: request.key, - reset: true, - stable: result.tokens - .flatMap((line, index) => - index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }], - ) - .map(token), - unstable: [], - }) - return - } - - const previous = streams.get(request.key) - const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source) - const stream = reset - ? { - language, - source: "", - tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: currentThemeName }), - } - : previous - const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length)) - stream.source = request.text - streams.set(request.key, stream) - post({ - type: "highlight", - id: request.id, - key: request.key, - reset, - stable: result.stable.filter((token) => token.content.length > 0).map(token), - unstable: result.unstable.filter((token) => token.content.length > 0).map(token), - }) - } catch (error) { - post({ - type: "error", - id: request.id, - key: request.key, - message: error instanceof Error ? error.message : String(error), - }) - } -} - -function post(response: MarkdownWorkerResponse) { - self.postMessage(response) -} - -function token(value: ThemedToken): MarkdownToken { - return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))] -} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx index 7aba7e336..bd40a630d 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/message-part.tsx @@ -1389,22 +1389,16 @@ export function ContextToolGroup(props: { props.actions?.openAttachment?.(file)} > - {typeLabel(name, file.mime)} + {typeLabel(name, file.mime, i18n.t("ui.common.file"))} ) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx index 7cdb34436..a1dc27ad2 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx @@ -1,17 +1,24 @@ import { createMemo } from "solid-js" import { AnimatedNumber } from "@opencode-ai/ui/animated-number" +import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" -export function splitCountLabel(text?: string) { - const value = text ?? "" - const match = /{{\s*count\s*}}/.exec(value) - if (!match) return { before: "", after: value } - if (match.index === undefined) return { before: "", after: value } +function split(text: string) { + const match = /{{\s*count\s*}}/.exec(text) + if (!match) return { before: "", after: text } + if (match.index === undefined) return { before: "", after: text } return { - before: value.slice(0, match.index), - after: value.slice(match.index + match[0].length), + before: text.slice(0, match.index), + after: text.slice(match.index + match[0].length), } } +// amicode: exported, null-safe wrapper over `split` — kept as a named export so +// the amicode-added tool-count-label.test.ts (which upstream does not ship) can +// exercise the {{count}} template-split edge cases directly. +export function splitCountLabel(text?: string) { + return split(text ?? "") +} + function common(one: string, other: string) { const a = Array.from(one) const b = Array.from(other) @@ -24,14 +31,16 @@ function common(one: string, other: string) { } } -export function AnimatedCountLabel(props: { count: number; one?: string; other?: string; class?: string }) { - const one = createMemo(() => splitCountLabel(props.one)) - const other = createMemo(() => splitCountLabel(props.other)) - const singular = createMemo(() => Math.round(props.count) === 1) - const active = createMemo(() => (singular() ? one() : other())) +export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) { + const i18n = useI18n() + const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count))) + const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one")))) + const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other")))) + const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category())))) const suffix = createMemo(() => common(one().after, other().after)) const splitSuffix = createMemo( () => + (category() === "one" || category() === "other") && one().before === other().before && (one().after.startsWith(other().after) || other().after.startsWith(one().after)), ) @@ -39,7 +48,7 @@ export function AnimatedCountLabel(props: { count: number; one?: string; other?: const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) const tail = createMemo(() => { if (!splitSuffix()) return "" - if (singular()) return suffix().one + if (category() === "one") return suffix().one return suffix().other }) const showTail = createMemo(() => splitSuffix() && tail().length > 0) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx index e96adeaac..78db4a102 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -1,5 +1,6 @@ import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { useI18n } from "@opencode-ai/ui/context/i18n" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" @@ -387,6 +388,7 @@ export function PromptInputV2Attachments(props: { onCommentClick?: (comment: PromptInputV2Comment) => void onCommentRemove?: (comment: PromptInputV2Comment) => void }) { + const i18n = useI18n() return ( 0 || (props.comments?.length ?? 0) > 0}>
@@ -430,7 +432,7 @@ export function PromptInputV2Attachments(props: { when={attachment.mime.startsWith("image/")} fallback={ - {typeLabel(attachment.filename, attachment.mime)} + {typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))} } > diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 6705b215b..34090101f 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -2,5 +2,5 @@ "version": "1.18.30", "base_version": "1.18.30", "base_commit": "3104c1428ec91f809e5ab86631300de41eb6952e", - "overlay_hash": "407819851d4e37b0933ace5b70ec9be81127172a2e0e5a94fd2d9697ae5225e9" + "overlay_hash": "fcbcc2c35b2b4566619dd5576dfea3f78ff3d593af5373727c3bb2f0726bc9c4" } From 4ebc03f822217a4c08fb0e8123dde2286087d481 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 24 Sep 2026 00:59:53 -0400 Subject: [PATCH 4/6] fix(app-bundle): satisfy the #987 tool-count-label i18n guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto main surfaced the amicode#987 guard test, which pins the exact shape of the tool-count-label overlay twin: a self-contained, undefined-safe splitCountLabel(text?: string) (the guard extracts and evals it verbatim). My earlier version delegated to upstream's internal split(), which the guard's evalHelpers could not resolve (ReferenceError: split is not defined) and which lacked the 'const value = text ?? ""' guard string. Inline the guarded split directly into splitCountLabel, drop the internal split(), and point AnimatedCountLabel's memos at splitCountLabel — keeping upstream v1.18.30's plural-key API. Guard passes 6/6; the undefined-t memo chain never throws (the production hub-panel crash path). --- packages/app-bundle/manifest.json | 2 +- .../src/components/tool-count-label.tsx | 33 ++++++++++--------- packages/extension/opencode.lock.json | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 41f3e5296..57594db86 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -410,7 +410,7 @@ "packages/session-ui/src/components/session-turn.css": "792915f3373134eadf3cd51e1d4bfa88ec347e6b037b6caf60b639ec5e74fbd8", "packages/session-ui/src/components/session-turn.tsx": "9cbc0d7a35f42e32dad53ace7b3c7d92623fa2d3108edb49e0b7bc868f5c1966", "packages/session-ui/src/components/tool-count-label.test.ts": "c62bbe2c662f6740794d25bd7c33fb3b9b46394a1b41d01d6568a8a2ee2b4e75", - "packages/session-ui/src/components/tool-count-label.tsx": "296ca8caf5e3d9a131a968cf1c13b386213eb5620b16bdc0f72cf9246fcafb35", + "packages/session-ui/src/components/tool-count-label.tsx": "06c96479710314d443d42d4c29a29b46d58a55ee84a844a5b6865b49a0513340", "packages/session-ui/src/components/tool-error-card.tsx": "e27b89836b7c709120ac15fbffd2200038944058ea3506e61e5897ffb3537503", "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a", "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx index a1dc27ad2..815cd4f9b 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/tool-count-label.tsx @@ -2,23 +2,24 @@ import { createMemo } from "solid-js" import { AnimatedNumber } from "@opencode-ai/ui/animated-number" import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" -function split(text: string) { - const match = /{{\s*count\s*}}/.exec(text) - if (!match) return { before: "", after: text } - if (match.index === undefined) return { before: "", after: text } +// amicode#987 — the guarded split. The base renders via split(i18n.t(...)), +// but i18n.t() returns undefined while the async locale dict is still loading +// (or for a missing key), and the unguarded split(undefined) crashed the hub +// panel inside a Solid effect (one().after.startsWith(...) on undefined). +// splitCountLabel coerces falsy text to "" — an honest empty render until the +// translation exists, matching the language.tsx fallback discipline. Exported +// so the #987 guard test extracts and exercises THIS function (not a copy). +export function splitCountLabel(text?: string) { + const value = text ?? "" + const match = /{{\s*count\s*}}/.exec(value) + if (!match) return { before: "", after: value } + if (match.index === undefined) return { before: "", after: value } return { - before: text.slice(0, match.index), - after: text.slice(match.index + match[0].length), + before: value.slice(0, match.index), + after: value.slice(match.index + match[0].length), } } -// amicode: exported, null-safe wrapper over `split` — kept as a named export so -// the amicode-added tool-count-label.test.ts (which upstream does not ship) can -// exercise the {{count}} template-split edge cases directly. -export function splitCountLabel(text?: string) { - return split(text ?? "") -} - function common(one: string, other: string) { const a = Array.from(one) const b = Array.from(other) @@ -34,9 +35,9 @@ function common(one: string, other: string) { export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) { const i18n = useI18n() const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count))) - const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one")))) - const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other")))) - const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category())))) + const one = createMemo(() => splitCountLabel(i18n.t(pluralKey(props.plural, "one")))) + const other = createMemo(() => splitCountLabel(i18n.t(pluralKey(props.plural, "other")))) + const active = createMemo(() => splitCountLabel(i18n.t(pluralKey(props.plural, category())))) const suffix = createMemo(() => common(one().after, other().after)) const splitSuffix = createMemo( () => diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 34090101f..1c77fca23 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -2,5 +2,5 @@ "version": "1.18.30", "base_version": "1.18.30", "base_commit": "3104c1428ec91f809e5ab86631300de41eb6952e", - "overlay_hash": "fcbcc2c35b2b4566619dd5576dfea3f78ff3d593af5373727c3bb2f0726bc9c4" + "overlay_hash": "df1225cea458c73d994e32522d677ff241ff4e7b53579117ab45dbd15f0be7c7" } From 972020d25e76e49a44e4367ddf926287a1395ee8 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 24 Sep 2026 01:55:56 -0400 Subject: [PATCH 5/6] fix(app-bundle): unshadow the v1.18.30 codex gpt-6 model filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI Codex plugin's models() filter (runs when OAuth'd) dropped any model whose id lacked a dotted minor version: the regex was /^gpt-(\d+\.\d+)/, so gpt-5.6 matched but gpt-6-astra/sol/luna (no .minor) were filtered out — invisible in the native openai picker while Bedrock/GCP (which bypass this filter) showed them. Our overlay codex.ts was authored on v1.18.29 and shadowed the base: v1.18.30 already fixed the regex to /^gpt-(\d+)(?:\.(\d+))?/ with a major>5 || (major===5 && minor>4) gate. Took the v1.18.30 base version (which also carries the compute-residency header path) and re-applied our one customization: the EADDRINUSE OAuth-port friendly error, rebranded to Amicode. Verified: the built binary now lists openai/gpt-6-astra|sol|luna (+ -fast mode variants); engine + overlay typecheck gates green. --- packages/app-bundle/manifest.json | 2 +- .../opencode/src/plugin/openai/codex.ts | 42 ++++++++++++------- packages/extension/opencode.lock.json | 2 +- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 57594db86..8e65c7f34 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -712,7 +712,7 @@ "packages/core/src/session/runner/llm.ts": "d11a8b4f31b37e16f16a14acd3a12fb45a323a7d8c460c21e67f94c3a2307b45", "packages/core/src/v1/config/config.ts": "6eece12da68ac65a2c8b2fe19b1f2509f397cb63d004dca3998cf6dcc772293e", "packages/core/src/v1/config/migrate.ts": "6c960e1245db9a4bb830f0c1ebd6174e50570b4282d4ce5d7bff3333277abcf4", - "packages/opencode/src/plugin/openai/codex.ts": "7f4342ee6a25eca6b66a47732a154449fbf318f6ce70ed446cf3e6788833954b", + "packages/opencode/src/plugin/openai/codex.ts": "e7f3988ad2afca6b0112cc820729372f774bfa04ecf3d348ed82b2bbf1b07c40", "packages/opencode/src/server/amicode/connections.ts": "dcfeeeddb380e11d8653c77e0dffa4ac3acabed4091f07f88b6ce0c7ae5772b3", "packages/opencode/src/server/amicode/credentials.ts": "f3a15c82496f9a3b6e8c1006a6a4ab419a02156e9a93a43c6c2319632c30063f", "packages/opencode/src/server/amicode/dashboard.ts": "031a4f40bfdc847984a1deecfefbe0fd27f42cbd1caf8823dc43c7eb5bcb9af0", diff --git a/packages/app-bundle/overlay/packages/opencode/src/plugin/openai/codex.ts b/packages/app-bundle/overlay/packages/opencode/src/plugin/openai/codex.ts index a14421bd6..b0c770c26 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/plugin/openai/codex.ts @@ -37,10 +37,12 @@ function base64UrlEncode(buffer: ArrayBuffer): string { export interface IdTokenClaims { chatgpt_account_id?: string + chatgpt_compute_residency?: string organizations?: Array<{ id: string }> email?: string "https://api.openai.com/auth"?: { chatgpt_account_id?: string + chatgpt_compute_residency?: string } } @@ -75,6 +77,14 @@ export function extractAccountId(tokens: TokenResponse): string | undefined { return undefined } +export function extractResidency(token: string): string | undefined { + const claims = parseJwtClaims(token) + const residency = + claims?.["https://api.openai.com/auth"]?.chatgpt_compute_residency ?? claims?.chatgpt_compute_residency + if (!residency || residency === "no_constraint") return undefined + return residency +} + function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { const params = new URLSearchParams({ response_type: "code", @@ -225,7 +235,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } if (error.code === "EADDRINUSE") { reject( new Error( - "Another OpenCode window is already waiting for an OpenAI sign-in. Finish or close the other sign-in, then try again.", + "Another Amicode window is already waiting for an OpenAI sign-in. Finish or close the other sign-in, then try again.", ), ) return @@ -298,8 +308,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug if (ALLOWED_MODELS.has(model.api.id)) return true if (DISALLOWED_MODELS.has(model.api.id)) return false if (model.api.id === "gpt-5.6") return false - const match = model.api.id.match(/^gpt-(\d+\.\d+)/) - return match ? parseFloat(match[1]) > 5.4 : false + const match = model.api.id.match(/^gpt-(\d+)(?:\.(\d+))?/) + if (!match) return false + const major = Number(match[1]) + const minor = Number(match[2] ?? 0) + return major > 5 || (major === 5 && minor > 4) }) .map(([modelID, model]) => [ modelID, @@ -310,16 +323,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug output: 0, cache: { read: 0, write: 0 }, }, - limit: model.id.includes("gpt-5.5") - ? { - context: 400_000, - input: 272_000, - output: 128_000, - } - : model.id.includes("gpt-5.6") + limit: + model.id.includes("gpt-5.5") || model.id.includes("gpt-5.6") ? { - context: 500_000, - input: 372_000, + context: 400_000, + input: 272_000, output: 128_000, } : model.limit, @@ -422,10 +430,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug requestInput instanceof URL ? requestInput : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) - const url = - parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") - ? new URL(codexApiEndpoint) - : parsed + const rewrite = parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") + const url = rewrite ? new URL(codexApiEndpoint) : parsed + if (rewrite) { + const residency = extractResidency(currentAuth.access) + if (residency) headers.set("x-openai-internal-codex-residency", residency) + } const requestInit = { ...init, diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 1c77fca23..e66dbfab3 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -2,5 +2,5 @@ "version": "1.18.30", "base_version": "1.18.30", "base_commit": "3104c1428ec91f809e5ab86631300de41eb6952e", - "overlay_hash": "df1225cea458c73d994e32522d677ff241ff4e7b53579117ab45dbd15f0be7c7" + "overlay_hash": "05f89265a9c3a4c0b32319b57fd019aa133c7cd620a8ff90ab6eea42acb64252" } From 0c9b5349797fff511f9fe03c33d4300b5b12e358 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 24 Sep 2026 02:19:51 -0400 Subject: [PATCH 6/6] chore(release): bump extension manifest to 0.3.7 for the overlay-sync alpha The v1.18.30 overlay sync (base bump + provider/model reconciliation + CI repairs + i18n drift + gpt-6 codex-filter fix) ships as 0.3.7. The release version guard requires the tag base (v0.3.7-alpha.1) to equal this manifest version. --- packages/extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index ecbdd91c9..1b3c46b8c 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,7 +2,7 @@ "name": "amicode", "displayName": "Amicode", "description": "Open autonomous research in VS Code \u2014 vaults, fleet, and live solves for quantum control and physical intelligence.", - "version": "0.3.6", + "version": "0.3.7", "publisher": "harmoniqs", "license": "Apache-2.0", "icon": "media/icon.png",