mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:56:24 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eca6ae42a | ||
|
|
c3f1bdaf97 | ||
|
|
2bf9bec897 | ||
|
|
90dd682e66 | ||
|
|
64684b118f | ||
|
|
cab8e39ad5 | ||
|
|
5165d6008c | ||
|
|
4d74854e8c |
@@ -86,6 +86,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
@@ -3228,6 +3229,8 @@
|
||||
|
||||
"@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="],
|
||||
|
||||
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
|
||||
|
||||
"@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
@@ -5160,6 +5163,8 @@
|
||||
|
||||
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
|
||||
|
||||
"qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="],
|
||||
|
||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createWebApp(domain: string) {
|
||||
$app.stage === "beta"
|
||||
? {
|
||||
OPENCODE_CHANNEL: "beta",
|
||||
VITE_OPENCODE_SERVER_MODE: "none",
|
||||
VITE_SENTRY_ENVIRONMENT: "beta",
|
||||
}
|
||||
: undefined,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-gW+1JbPQnt+PZzHfGijjnwch6X6pyaBnPTxAc47yiQw=",
|
||||
"aarch64-linux": "sha256-qI6CtJkmh20ouWeiQRR1zp+YjHootkN23VFJCwqnf0A=",
|
||||
"aarch64-darwin": "sha256-oyEhVcjbKq3+BCCxVBl0TZYmKJv1BBLWPVs0Vw6Fbac=",
|
||||
"x86_64-darwin": "sha256-UhLZjG3NlkoXRWNn8SK/pLm5eDR3saU+TgEgfX592vQ="
|
||||
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
|
||||
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
|
||||
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
|
||||
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +50,24 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = {
|
||||
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
|
||||
}
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
|
||||
| ({
|
||||
readonly type: "enabled"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
} & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
@@ -301,20 +310,27 @@ const AnthropicToolChoice = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
const AnthropicJsonOutputFormat = Schema.Struct({
|
||||
@@ -1025,8 +1041,9 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
||||
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
|
||||
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
|
||||
}
|
||||
const thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: yield* resolveThinking(input?.thinking),
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
output_config,
|
||||
service_tier,
|
||||
@@ -1037,15 +1054,41 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsThinkingBlockBinding
|
||||
if (override !== undefined) return override
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const version = /(?:^|[./])claude-[a-z]+-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups
|
||||
if (!version) return false
|
||||
const major = Number(version.major)
|
||||
const minor = Number(version.minor ?? 0)
|
||||
return major > 5 || (major === 5 && minor >= 1)
|
||||
}
|
||||
|
||||
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
|
||||
if (thinking?.type === "disabled") return thinking
|
||||
if (!supportsThinkingBlockBinding(model)) return thinking
|
||||
return {
|
||||
...(thinking ?? { type: "adaptive" as const }),
|
||||
block_binding: {
|
||||
prefix_mismatch_behavior: "drop_block",
|
||||
...thinking?.block_binding,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
|
||||
const block_binding = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
|
||||
)(input.block_binding)
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
@@ -1054,7 +1097,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
|
||||
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
@@ -1636,24 +1679,21 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
|
||||
export const transport = <
|
||||
Body extends Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">,
|
||||
>() => {
|
||||
const http = HttpTransport.httpJson<Body, string>({ framing })
|
||||
return {
|
||||
...http,
|
||||
prepare: (input: Parameters<typeof http.prepare>[0]) => {
|
||||
if (
|
||||
!input.body.context_management?.edits.length &&
|
||||
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
|
||||
)
|
||||
return http.prepare(input)
|
||||
const requiredBetas = requiredBetaHeaders(input.body)
|
||||
if (requiredBetas.length === 0) return http.prepare(input)
|
||||
const headers = Headers.fromInput(input.request.http?.headers)
|
||||
const betas = new Set(
|
||||
(headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
betas.add("compact-2026-01-12")
|
||||
const existingBetas = (headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const betas = new Set([...existingBetas, ...requiredBetas])
|
||||
return http.prepare({
|
||||
...input,
|
||||
request: LLMRequest.update(input.request, {
|
||||
@@ -1667,6 +1707,20 @@ export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" |
|
||||
}
|
||||
}
|
||||
|
||||
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
|
||||
const betas: string[] = []
|
||||
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
|
||||
const replaysCompaction = body.messages.some((message) =>
|
||||
message.content.some((block) => block.type === "compaction"),
|
||||
)
|
||||
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
|
||||
|
||||
const thinking = body.thinking
|
||||
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
|
||||
betas.push("thinking-binding-controls-2026-08-01")
|
||||
return betas
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "anthropic",
|
||||
|
||||
@@ -163,6 +163,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
zaiToolStream: Schema.optional(Schema.Boolean),
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
|
||||
supportsThinkingBlockBinding: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelCompatibility {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
for (const [id, enabled] of [
|
||||
["claude-fable-5-1", true],
|
||||
["claude-mythos-5-1", true],
|
||||
["claude-fable-5.1", true],
|
||||
["anthropic/claude-fable-5.1", true],
|
||||
["claude-fable-5-1@default", true],
|
||||
["claude-fable-5-1@20260901", true],
|
||||
["claude-sonnet-6", true],
|
||||
["claude-opus-5-20260901", false],
|
||||
["claude-opus-4-8", false],
|
||||
["anthropic/claude-opus-4.8", false],
|
||||
["claude-fable-5@default", false],
|
||||
["kimi-k2.5", false],
|
||||
] as const) {
|
||||
it.effect(`thinking-binding defaults for ${id}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: AnthropicMessages.route.model({ id }),
|
||||
prompt: "Hello",
|
||||
http: { headers: { "anthropic-beta": "existing-beta" } },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
|
||||
expect(compiled.body.thinking).toEqual(
|
||||
enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined,
|
||||
)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta",
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("preserves explicit thinking settings and combines required beta headers", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const thinking of [
|
||||
{ type: "disabled" },
|
||||
{ type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } },
|
||||
] as const) {
|
||||
const request = LLM.request({
|
||||
model: AnthropicMessages.route.model({ id: "claude-fable-5-1" }),
|
||||
prompt: "Hello",
|
||||
providerOptions: { thinking, contextManagement: { edits: [{ type: "compact_20260112" }] } },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
|
||||
expect(compiled.body.thinking).toEqual(thinking)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01",
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
+33
-2
@@ -83,8 +83,39 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
|
||||
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
|
||||
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
|
||||
|
||||
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
|
||||
override this default. Connecting still requires the service's credentials.
|
||||
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
|
||||
|
||||
| Mode | Initial server |
|
||||
| ------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `none` | No initial server. The beta deployment uses this mode. |
|
||||
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
|
||||
|
||||
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
|
||||
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
|
||||
from storage. Desktop provides the local server it discovers or starts through native initialization.
|
||||
|
||||
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
|
||||
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
|
||||
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
|
||||
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
|
||||
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
|
||||
|
||||
When the service is exposed through an HTTPS reverse proxy, advertise its external address at runtime:
|
||||
|
||||
```bash
|
||||
opencode pair --url https://your-machine.your-tailnet.ts.net
|
||||
```
|
||||
|
||||
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
|
||||
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
|
||||
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
|
||||
|
||||
When an HTTPS page fails to connect to a non-loopback HTTP server, the connection forms show a specific
|
||||
HTTPS-to-HTTP error instead of the generic connection failure. HTTP servers on `localhost`, `*.localhost`,
|
||||
`127.0.0.0/8`, or `::1` are treated as trustworthy loopback targets. Connection attempts still run, since
|
||||
browser local-network permissions can allow some HTTP LAN connections.
|
||||
QR scanning is enabled only in a browser-reported secure context with camera support and an available
|
||||
video input; insecure pages and unavailable cameras show an explanation beside the disabled action.
|
||||
|
||||
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
|
||||
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
|
||||
|
||||
@@ -99,13 +99,16 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
await modelControl.click()
|
||||
await page.locator('[data-option-key="opencode:free-model"]').click()
|
||||
const modelSearch = page.getByPlaceholder("Search models", { exact: true })
|
||||
await expect(modelSearch).toBeFocused()
|
||||
await modelSearch.press("ArrowDown")
|
||||
await modelSearch.press("Enter")
|
||||
await expect(modelControl).toContainText("Free Model")
|
||||
|
||||
await modelControl.click()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
await expect(modelSearch).toBeFocused()
|
||||
await modelSearch.press("ArrowUp")
|
||||
await modelSearch.press("Enter")
|
||||
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
})
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AppBaseProviders(
|
||||
|
||||
export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
|
||||
const accepted = [
|
||||
@@ -107,7 +109,7 @@ export function createComposerAttachments(
|
||||
if (toast) input.warn()
|
||||
return false
|
||||
}
|
||||
const blob = input.store ? await input.store(file) : await blobReference(file)
|
||||
const blob = input.store ? await input.store(file) : await createBlobReference(file)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
|
||||
// clipboard content is matched on bytes alone.
|
||||
@@ -127,7 +129,7 @@ export function createComposerAttachments(
|
||||
}
|
||||
const attachment: ComposerAttachment = {
|
||||
type: "image",
|
||||
id: crypto.randomUUID(),
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
@@ -230,12 +232,6 @@ export function createComposerAttachments(
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
async function blobReference(file: File) {
|
||||
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer())))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
return { id, url: URL.createObjectURL(file) }
|
||||
}
|
||||
const imageExtensions = new Map([
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
|
||||
+13
-11
@@ -76,22 +76,24 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
const standalone = isStandalone()
|
||||
root.dataset.standalone = String(standalone)
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
const server: ServerConnection.Http | undefined = web.currentServerUrl
|
||||
? {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={web.platform}>
|
||||
<AppBaseProviders locale={locale}>
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
|
||||
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
|
||||
servers={server ? [server] : []}
|
||||
>
|
||||
<KeyboardInsets />
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OPENCODE_SERVER_MODE?: "none" | "origin"
|
||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
|
||||
|
||||
@@ -198,26 +198,30 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
<Show
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="mt-1 disabled:opacity-60"
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
<Show when={props.servers[0]}>
|
||||
{(server) => (
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={server()} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="mt-1 disabled:opacity-60"
|
||||
disabled={props.serverHealth(server())?.healthy === false}
|
||||
onClick={() => props.onChooseProject(server())}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
|
||||
|
||||
@@ -311,8 +311,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
dialog.show(() => <DeleteDialog server={server} session={session} />),
|
||||
},
|
||||
tab: {
|
||||
isOpen: (record: HomeSessionRecord) =>
|
||||
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
|
||||
isOpen: (record: HomeSessionRecord) => {
|
||||
const server = home.selection.value().server
|
||||
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { HomeScrollController } from "../scroll"
|
||||
import type { HomeSessionSearchController } from "./search"
|
||||
import type { HomeSessionsController } from "./controller"
|
||||
import { HomeSessionsView } from "./view"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
export function HomeSessions(props: {
|
||||
sessions: HomeSessionsController
|
||||
@@ -9,43 +10,47 @@ export function HomeSessions(props: {
|
||||
scroll: HomeScrollController
|
||||
}) {
|
||||
return (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups()}
|
||||
loading={props.sessions.data.loading()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={props.sessions.session.server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
searchOpen={props.search.query.open()}
|
||||
searchLoading={props.search.result.loading()}
|
||||
searchResults={props.search.result.list()}
|
||||
searchActive={props.search.result.active()}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onRenameSession={props.sessions.session.rename}
|
||||
onExportSession={props.sessions.session.export}
|
||||
onDeleteSession={props.sessions.session.showDelete}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
onSetHeader={props.scroll.header.setHeader}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onSetSearchRoot={props.search.element.setRoot}
|
||||
onSetSearchInput={props.search.element.setInput}
|
||||
onSetSearchList={props.search.element.setList}
|
||||
onSearchFocus={props.search.query.focus}
|
||||
onSearchInput={props.search.query.input}
|
||||
onSearchClose={props.search.query.close}
|
||||
onSearchMove={props.search.result.move}
|
||||
onSearchSelectActive={props.search.result.selectActive}
|
||||
onSearchHighlight={props.search.result.highlight}
|
||||
onSearchSelect={props.search.result.select}
|
||||
/>
|
||||
<Show when={props.sessions.session.server()}>
|
||||
{(server) => (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups()}
|
||||
loading={props.sessions.data.loading()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
searchOpen={props.search.query.open()}
|
||||
searchLoading={props.search.result.loading()}
|
||||
searchResults={props.search.result.list()}
|
||||
searchActive={props.search.result.active()}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onRenameSession={props.sessions.session.rename}
|
||||
onExportSession={props.sessions.session.export}
|
||||
onDeleteSession={props.sessions.session.showDelete}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
onSetHeader={props.scroll.header.setHeader}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onSetSearchRoot={props.search.element.setRoot}
|
||||
onSetSearchInput={props.search.element.setInput}
|
||||
onSetSearchList={props.search.element.setList}
|
||||
onSearchFocus={props.search.query.focus}
|
||||
onSearchInput={props.search.query.input}
|
||||
onSearchClose={props.search.query.close}
|
||||
onSearchMove={props.search.result.move}
|
||||
onSearchSelectActive={props.search.result.selectActive}
|
||||
onSearchHighlight={props.search.result.highlight}
|
||||
onSearchSelect={props.search.result.select}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<Menu.SubContent class="max-h-[224px] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
|
||||
@@ -314,7 +314,7 @@ function ModelSelectorPopoverView(props: {
|
||||
|
||||
const models = createMemo(() => props.models(store.search))
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const keys = () => [...groups().flatMap((group) => group.items.map(modelKey)), manageKey]
|
||||
const initialActive = () => {
|
||||
const selected = props.current
|
||||
const options = keys()
|
||||
|
||||
@@ -366,6 +366,25 @@ export const dict = {
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.password": "Password",
|
||||
"dialog.server.add.passwordPlaceholder": "password",
|
||||
|
||||
"server.connect.title": "Connect to a server",
|
||||
"server.connect.description": "Enter your server address and password to get started.",
|
||||
"server.connect.button": "Connect",
|
||||
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
|
||||
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
|
||||
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
|
||||
"server.connect.scan": "Scan QR code",
|
||||
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
|
||||
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
|
||||
"server.connect.camera": "Pairing camera",
|
||||
"server.connect.camera.starting": "Opening camera…",
|
||||
"server.connect.mixedContent":
|
||||
"Could not connect to this HTTP server from an HTTPS page. Use an HTTPS server address instead.",
|
||||
"server.connect.camera.insecure": "QR scanning requires opening this page over HTTPS or on localhost.",
|
||||
"server.connect.camera.unavailable":
|
||||
"No camera is available to this browser. Enter your connection details manually.",
|
||||
"server.connect.camera.error":
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
|
||||
}
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
|
||||
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Data } from "@opencode/client/solid"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
|
||||
type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string }
|
||||
|
||||
@@ -35,7 +36,7 @@ export function createSessionMutations(remove: (sessionID: string) => Promise<vo
|
||||
return removed.size === 0 ? [...sessions] : sessions.filter((session) => !removed.has(session.id))
|
||||
},
|
||||
remove(sessionID: string) {
|
||||
const mutation = { id: crypto.randomUUID(), type: "remove" as const, sessionID }
|
||||
const mutation = { id: uuid(), type: "remove" as const, sessionID }
|
||||
setStore("session", (current) => [...current, mutation])
|
||||
return Promise.resolve()
|
||||
.then(() => remove(sessionID))
|
||||
|
||||
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
name: "Server",
|
||||
gate: true,
|
||||
init: (props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { serverAddress } from "./pairing"
|
||||
|
||||
export function isMixedContent(page: string, address: string) {
|
||||
if (new URL(page).protocol !== "https:") return false
|
||||
const normalized = serverAddress(address)
|
||||
if (!normalized) return false
|
||||
const url = new URL(normalized)
|
||||
if (url.protocol !== "http:") return false
|
||||
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
|
||||
const host = url.hostname.replace(/\.$/, "")
|
||||
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { normalizeServerUrl, ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { isMixedContent } from "./browser"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
@@ -81,11 +83,14 @@ export const DialogServer: Component<{
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={form.state.error()}>
|
||||
<span class="settings-server-dialog-error">{form.state.error()}</span>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -129,6 +134,7 @@ export const DialogServer: Component<{
|
||||
}
|
||||
|
||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
const platform = usePlatform()
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
@@ -201,7 +207,14 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
}
|
||||
const result = await checkServerHealth(connection.http)
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
setStore(
|
||||
"error",
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, normalized)
|
||||
? "server.connect.mixedContent"
|
||||
: "dialog.server.add.error",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (original?.type === "http") {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized || !URL.canParse(normalized)) return
|
||||
const url = new URL(normalized)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return
|
||||
if (url.username || url.password || url.search || url.hash) return
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QrScanner from "qr-scanner"
|
||||
import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ error: "", ready: false })
|
||||
const video = document.createElement("video")
|
||||
video.setAttribute("aria-label", language.t("server.connect.camera"))
|
||||
video.setAttribute("playsinline", "")
|
||||
video.muted = true
|
||||
|
||||
onMount(() => {
|
||||
// QrScanner hides detached videos, so initialize only after this preview is mounted.
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingCode(result.data)
|
||||
if (!pairing) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
}
|
||||
scanner.stop()
|
||||
props.onScan(pairing)
|
||||
},
|
||||
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
|
||||
)
|
||||
// Terminal QR codes can be light-on-dark depending on the terminal theme.
|
||||
scanner.setInversionMode("both")
|
||||
onCleanup(() => scanner.destroy())
|
||||
void scanner.start().then(
|
||||
() => setState("ready", true),
|
||||
() => setState("error", language.t("server.connect.camera.error")),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
|
||||
<p>{language.t("server.connect.scan.description")}</p>
|
||||
<div class="server-connect-video">
|
||||
{video}
|
||||
<Show when={!state.ready && !state.error}>
|
||||
<span role="status">{language.t("server.connect.camera.starting")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={state.error}>
|
||||
<p class="server-connect-error" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
</Show>
|
||||
<Button variant="neutral" size="large" onClick={props.onCancel}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
[data-component="connect-server"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
|
||||
max(32px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
|
||||
background: var(--v2-background-bg-base);
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
.server-connect-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 360px);
|
||||
margin: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.server-connect-brand {
|
||||
width: 160px;
|
||||
margin-inline: auto;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 530;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
form,
|
||||
.server-connect-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.server-connect-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
[data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
[data-slot="text-input-v2-input"] {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-connect-error {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-block-start: 16px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
margin-block-start: 12px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { Wordmark } from "@opencode/ui/wordmark"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const cameraSupported =
|
||||
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [camera, cameraActions] = createResource(
|
||||
async () => {
|
||||
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, state.url)
|
||||
? "server.connect.mixedContent"
|
||||
: "server.connect.failed",
|
||||
)
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const url = serverAddress(state.url)
|
||||
if (!url) {
|
||||
setState("error", language.t("server.connect.address.invalid"))
|
||||
return
|
||||
}
|
||||
const http = { url, password: state.password || undefined }
|
||||
const result = await check(http)
|
||||
if (!result.healthy) {
|
||||
setState("error", connectionError())
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
},
|
||||
onError: () => setState("error", connectionError()),
|
||||
}))
|
||||
|
||||
return (
|
||||
<main data-component="connect-server" aria-labelledby="server-connect-title">
|
||||
<div class="server-connect-content">
|
||||
<div class="server-connect-brand" role="img" aria-label="OpenCode">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<header>
|
||||
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
|
||||
<p>{language.t("server.connect.description")}</p>
|
||||
</header>
|
||||
<Show
|
||||
when={!state.scanning}
|
||||
fallback={
|
||||
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
setState("scanning", false)
|
||||
void cameraActions.refetch()
|
||||
}}
|
||||
onScan={(pairing) => {
|
||||
setState({
|
||||
url: pairing.urls[0],
|
||||
urls: pairing.urls,
|
||||
password: pairing.password,
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
request.mutate()
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (request.isPending) return
|
||||
setState("error", "")
|
||||
request.mutate()
|
||||
}}
|
||||
>
|
||||
<div class="server-connect-field">
|
||||
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
id="server-connect-url"
|
||||
name="server"
|
||||
dir="ltr"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
autocomplete="url"
|
||||
autocapitalize="off"
|
||||
spellcheck={false}
|
||||
required
|
||||
appearance="large"
|
||||
list="server-connect-addresses"
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
value={state.url}
|
||||
disabled={request.isPending}
|
||||
aria-describedby={state.error ? "server-connect-error" : undefined}
|
||||
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
|
||||
/>
|
||||
<datalist id="server-connect-addresses">
|
||||
{state.urls.map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="server-connect-field">
|
||||
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
id="server-connect-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
appearance="large"
|
||||
value={state.password}
|
||||
disabled={request.isPending}
|
||||
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
|
||||
/>
|
||||
</div>
|
||||
<Show when={state.error}>
|
||||
<p id="server-connect-error" class="server-connect-error" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
</Show>
|
||||
<Button type="submit" variant="contrast" size="large" disabled={request.isPending || !state.url.trim()}>
|
||||
{language.t(request.isPending ? "dialog.server.add.checking" : "server.connect.button")}
|
||||
</Button>
|
||||
</form>
|
||||
<Show when={platform.platform === "web"}>
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
disabled={request.isPending || !camera.latest}
|
||||
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
|
||||
onClick={() => setState("scanning", true)}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.latest && !camera.loading}>
|
||||
<p id="server-connect-camera-unavailable">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
)}
|
||||
</p>
|
||||
</Show>
|
||||
</Show>
|
||||
<footer>
|
||||
<p>{language.t("server.connect.pair.description")}</p>
|
||||
<code dir="ltr">opencode pair</code>
|
||||
</footer>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -194,7 +194,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
|
||||
const openTerminal = () => {
|
||||
actions.session.layout.view().terminal.open()
|
||||
if (terminal.all().length > 0) terminal.new({ focus: true })
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
if (terminal.all().length === 0) terminal.requestFocus()
|
||||
}
|
||||
|
||||
|
||||
@@ -244,9 +244,9 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", [])
|
||||
})
|
||||
},
|
||||
new(options?: { focus?: boolean }) {
|
||||
new() {
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
const focusRequest = requestFocus(undefined, true)
|
||||
|
||||
const doCreate = async () => {
|
||||
return serverSDK.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
|
||||
@@ -255,7 +255,7 @@ function createWorkspaceTerminalSession(
|
||||
.then((data) => {
|
||||
const id = data?.id
|
||||
if (!id) {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
cancelFocus(focusRequest)
|
||||
return
|
||||
}
|
||||
const newTerminal = {
|
||||
@@ -266,13 +266,13 @@ function createWorkspaceTerminalSession(
|
||||
batch(() => {
|
||||
setStore("all", store.all.length, newTerminal)
|
||||
setStore("active", id)
|
||||
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
|
||||
if (ui.focus?.request === focusRequest) {
|
||||
setUi("focus", { request: focusRequest, id, pending: false })
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||
cancelFocus(focusRequest)
|
||||
console.error("Failed to create terminal", error)
|
||||
})
|
||||
},
|
||||
@@ -440,7 +440,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: (options?: { focus?: boolean }) => workspace().new(options),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
|
||||
@@ -330,7 +330,7 @@ export function TerminalPanel(
|
||||
<IconButton
|
||||
icon={<Icon name="plus-small" size="large" />}
|
||||
variant="ghost"
|
||||
onClick={() => terminal.new({ focus: true })}
|
||||
onClick={() => terminal.new()}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
@@ -15,6 +15,9 @@ export const File = lazy(() => import("@opencode/session-ui/file").then((module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
|
||||
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
|
||||
const ConnectServerScreen = lazy(() =>
|
||||
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
|
||||
)
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
@@ -71,13 +74,16 @@ function TargetServerRoute(props: ParentProps) {
|
||||
}
|
||||
|
||||
function AppLayout(props: ParentProps) {
|
||||
const servers = useServers()
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
|
||||
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
|
||||
home: Persistence.struct({
|
||||
selection: Persistence.struct({
|
||||
server: TabStorage.ServerKey,
|
||||
server: Schema.optional(TabStorage.ServerKey),
|
||||
directory: Schema.optional(Schema.String),
|
||||
}),
|
||||
}),
|
||||
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
|
||||
),
|
||||
)
|
||||
|
||||
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
|
||||
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
|
||||
return {
|
||||
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
|
||||
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
|
||||
@@ -233,7 +233,7 @@ export function initialLayout(server: ServerConnection.Key): typeof layoutSchema
|
||||
mobileSidebar: { opened: false },
|
||||
sessionTabs: {},
|
||||
sessionView: {},
|
||||
home: { selection: { server } },
|
||||
home: { selection: server ? { server } : {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...Persist.global("layout"), previousKey: "layout.v6" },
|
||||
layoutPersistence,
|
||||
initialLayout(ServerConnection.key(servers.list[0])),
|
||||
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
|
||||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
|
||||
@@ -6,7 +6,9 @@ import { collectFiles } from "./files"
|
||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||
if (options?.skipBuild) return compress({})
|
||||
const root = path.resolve(import.meta.dirname, "../../app")
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
await $`bun run build`
|
||||
.cwd(root)
|
||||
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
|
||||
@@ -431,7 +431,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("pair", { description: "Show server pairing information" }),
|
||||
Spec.make("pair", {
|
||||
description: "Show server pairing information",
|
||||
params: {
|
||||
url: Flag.string("url").pipe(
|
||||
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
const url = new URL(value)
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
||||
throw new Error("Invalid pairing URL")
|
||||
return url.href.replace(/\/+$/, "")
|
||||
},
|
||||
() => "Expected an HTTP(S) server URL without credentials, query parameters, or a fragment",
|
||||
),
|
||||
Flag.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
@@ -9,13 +9,15 @@ import { ServiceConfig } from "../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* () {
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const password = yield* ServiceConfig.password()
|
||||
const server = yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
|
||||
)
|
||||
const info = { urls: server.urls, username: "opencode", password }
|
||||
const urls = Option.isSome(input.url)
|
||||
? [input.url.value]
|
||||
: (yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
@@ -34,6 +36,7 @@ export default Runtime.handler(
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
|
||||
if (Option.isSome(input.url)) return
|
||||
const hostname = new URL(endpoint.url).hostname
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
|
||||
|
||||
@@ -53,7 +53,3 @@ Use the instructions below and the tools available to assist the user.
|
||||
|
||||
# Code Style - Comments
|
||||
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
|
||||
|
||||
# User Help & Feedback
|
||||
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta {{MODEL_NAME}}.
|
||||
- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the `webfetch` tool to gather information to answer the question from the V2 OpenCode docs at https://opencode.ai/v2/docs/.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Command } from "@opencode/core/command"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
@@ -33,11 +33,6 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("CommandPlugin.Plugin", () => {
|
||||
test("refers to tools by their available capabilities", () => {
|
||||
expect(PROMPT_REVIEW).toContain("Available documentation and code-search tools")
|
||||
expect(PROMPT_REVIEW).not.toContain("Exa Code Context")
|
||||
})
|
||||
|
||||
it.effect("registers built-in init and review commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
|
||||
@@ -15,6 +15,10 @@ import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import PROMPT_META from "../../src/plugin/system-prompt/meta.txt"
|
||||
import PROMPT_GPT from "../../src/plugin/system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "../../src/plugin/system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "../../src/plugin/system-prompt/kimi.txt"
|
||||
import PROMPT_TRINITY from "../../src/plugin/system-prompt/trinity.txt"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const fallback = SessionSystemPrompt.make([])
|
||||
@@ -42,20 +46,6 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
})
|
||||
|
||||
describe("OptimizePlugin", () => {
|
||||
test("uses current vocabulary in the Meta prompt", () => {
|
||||
expect(PROMPT_META).toContain("`webfetch` tool")
|
||||
expect(PROMPT_META).toContain("`subagent` tool")
|
||||
expect(PROMPT_META).toContain("Reserve `shell`")
|
||||
expect(PROMPT_META).toContain("`read` for reading files")
|
||||
expect(PROMPT_META).toContain("`edit` for editing")
|
||||
expect(PROMPT_META).toContain("`write` for creating files")
|
||||
expect(PROMPT_META).toContain("Follow that reminder for the files you may edit")
|
||||
expect(PROMPT_META).toContain("https://opencode.ai/v2/docs/")
|
||||
expect(PROMPT_META).not.toMatch(
|
||||
/TodoWrite|Task tool|WebFetch|\bBash\b|including planning files|https:\/\/opencode\.ai\/docs/,
|
||||
)
|
||||
})
|
||||
|
||||
test("enables prompt plugins without model-specific tool optimization", () => {
|
||||
expect(OptimizePlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
@@ -81,16 +71,16 @@ describe("OptimizePlugin", () => {
|
||||
discard: true,
|
||||
})
|
||||
const cases = [
|
||||
["gpt-5", "# Delegation"],
|
||||
["gpt-4.1", "# Delegation"],
|
||||
["gpt-5", PROMPT_GPT],
|
||||
["gpt-4.1", PROMPT_GPT],
|
||||
["o3", fallback],
|
||||
["gpt-5-codex", "# Delegation"],
|
||||
["gpt-6-astra", "Do not settle for a partial"],
|
||||
["gpt-5-codex", PROMPT_GPT],
|
||||
["gpt-6-astra", PROMPT_ASTRA],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", fallback],
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "powered by Muse Spark"],
|
||||
["kimi-k2", PROMPT_KIMI],
|
||||
["trinity", PROMPT_TRINITY],
|
||||
["meta/muse-spark-1.1", PROMPT_META.replaceAll("{{MODEL_NAME}}", "Muse Spark")],
|
||||
["llama-3.3", fallback],
|
||||
] as const
|
||||
|
||||
@@ -102,7 +92,11 @@ describe("OptimizePlugin", () => {
|
||||
.trigger("session", "context", event)
|
||||
.pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => expect(event.system.map((part) => part.text).join("\n\n")).toContain(expected)),
|
||||
Effect.sync(() =>
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
SessionSystemPrompt.render(expected, Object.keys(event.tools)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -127,14 +121,10 @@ describe("OptimizePlugin", () => {
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
SessionSystemPrompt.render(PROMPT_GPT, Object.keys(event.tools)),
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text).toStartWith("You are an AI agent powered by OpenCode")
|
||||
expect(event.system[0]?.text).toContain("Prefer dedicated tools over shell commands")
|
||||
expect(event.system[0]?.text).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
|
||||
expect(event.system[0]?.text).toContain("Use the write tool")
|
||||
expect(event.system[0]?.text).toContain("Use the edit tool")
|
||||
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
|
||||
}),
|
||||
)
|
||||
@@ -187,14 +177,14 @@ describe("OptimizePlugin", () => {
|
||||
yield* OptimizePlugin.OpenAIToolsPlugin.effect(pluginHost)
|
||||
const event = context("gpt-5")
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
expect(event.system[0]?.text).toContain("# Delegation")
|
||||
expect(event.system[0]?.text).toBe(SessionSystemPrompt.render(PROMPT_GPT, Object.keys(event.tools)))
|
||||
expect(Object.keys(event.tools).sort()).toEqual(["edit", "patch", "read", "shell", "write"])
|
||||
}),
|
||||
)
|
||||
|
||||
const event = context("gpt-5")
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
expect(event.system[0]?.text).toContain("# Delegation")
|
||||
expect(event.system[0]?.text).toBe(SessionSystemPrompt.render(PROMPT_GPT, Object.keys(event.tools)))
|
||||
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
|
||||
const claude = context("claude-sonnet-4-6")
|
||||
yield* hooks.trigger("session", "context", claude)
|
||||
@@ -229,8 +219,9 @@ describe("OptimizePlugin", () => {
|
||||
return hooks.trigger("session", "context", event).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
expect(event.system[0]?.text).toContain(`powered by ${name},`)
|
||||
expect(event.system[0]?.text).toContain(`using Meta ${name}.`)
|
||||
expect(event.system[0]?.text).toBe(
|
||||
SessionSystemPrompt.render(PROMPT_META.replaceAll("{{MODEL_NAME}}", name), Object.keys(event.tools)),
|
||||
)
|
||||
expect(event.system[0]?.text).not.toContain("{{MODEL_NAME}}")
|
||||
}),
|
||||
),
|
||||
@@ -292,7 +283,7 @@ describe("OptimizePlugin", () => {
|
||||
yield* hooks.trigger("session", "context", kimi)
|
||||
|
||||
expect(gemini.system[0]?.text).toBe(fallback)
|
||||
expect(kimi.system[0]?.text).toContain("# Prompt and Tool Use")
|
||||
expect(kimi.system[0]?.text).toBe(SessionSystemPrompt.render(PROMPT_KIMI, Object.keys(kimi.tools)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -302,8 +293,8 @@ describe("OptimizePlugin", () => {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
const cases = [
|
||||
["gpt-5-alias", "custom-model", undefined, "# Delegation"],
|
||||
["gpt-6-alias", "custom-model", undefined, "Do not settle for a partial"],
|
||||
["gpt-5-alias", "custom-model", undefined, PROMPT_GPT],
|
||||
["gpt-6-alias", "custom-model", undefined, PROMPT_ASTRA],
|
||||
["openai-alias", "GPT-5", undefined, fallback],
|
||||
["codex-family-alias", "custom-deployment", "GPT-CODEX", fallback],
|
||||
["astra-api-alias", "gpt-6-astra", undefined, fallback],
|
||||
@@ -326,7 +317,7 @@ describe("OptimizePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const event = context(id)
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
expect(event.system[0]?.text).toContain(prompt)
|
||||
expect(event.system[0]?.text).toBe(SessionSystemPrompt.render(prompt, Object.keys(event.tools)))
|
||||
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
|
||||
}),
|
||||
{ discard: true },
|
||||
|
||||
Reference in New Issue
Block a user