Compare commits

..

24 Commits

Author SHA1 Message Date
kitlangton a15db5460c test(tui): stabilize tab mouse reflow 2026-08-25 13:14:22 +00:00
opencode-agent[bot] 5c25c38961 fix(tui): paste into active custom answers (#44849)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 18:19:28 +05:30
Brendan Allan e3aa13c7d0 fix(app): show pending states when switching servers (#44947) 2026-08-25 20:34:53 +08:00
opencode-agent[bot] 5963a30621 fix(ui): restore stacked dialog focus (#44941)
Co-authored-by: Brendan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 19:10:09 +08:00
opencode-agent[bot] bcd1769521 chore: update nix node_modules hashes 2026-08-25 09:05:45 +00:00
Brendan Allan 691cb456ae feat(desktop): add development component picker (#44922) 2026-08-25 16:48:22 +08:00
opencode-agent[bot] 190f189fbe fix(app): route notification clicks through tabs (#44897)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 14:11:59 +08:00
Major Hayden 8c126e98da fix(ci): check PR body for linked issue on non-default branches (#43964)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-25 00:38:02 -05:00
Aiden Cline ce8a489aaa fix(ai): respect prompt cache opt-out (#44891) 2026-08-25 00:36:18 -05:00
Brendan Allan 1f7ae3f638 fix(app): animate composer delivery controls (#44886) 2026-08-25 13:34:53 +08:00
Luke Parker 5ad0f0dc5a fix(app): prevent timeline row identity collisions (#44878) 2026-08-25 15:33:45 +10:00
opencode-agent[bot] e589969398 fix(core): resolve compatible shells for commands (#44485)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-25 00:24:08 -05:00
Aiden Cline 42867d3bbc fix(ai): recover incomplete streamed tool arguments (#44875) 2026-08-25 00:22:03 -05:00
Brendan Allan d4cdb99e4c fix(desktop): suppress resize observer loop warnings (#44883) 2026-08-25 13:17:29 +08:00
Aiden Cline 0a78b11222 fix(ai): ignore unknown Anthropic stream variants (#44817) 2026-08-25 00:03:08 -05:00
Aiden Cline 28c1806950 fix(core): route Copilot fallback models through AI SDK (#44882) 2026-08-25 00:01:42 -05:00
opencode-agent[bot] 683f5fdee0 fix(tui): inherit model for new sessions (#44879)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 23:58:09 -05:00
Dax Raad e9b5e055f5 fix(docs): refine header links and copy feedback 2026-08-25 00:53:10 -04:00
Aiden Cline f327adb0f2 fix(core): support Zod tool schemas (#44861) 2026-08-24 23:50:01 -05:00
Dax Raad 442bc92a21 feat(docs): add markdown copy button to page headings 2026-08-25 00:48:39 -04:00
Dax Raad f2ff93a5b7 fix(www): use official favicon 2026-08-25 00:44:42 -04:00
Aiden Cline 1b30098e8d fix(ai): default responses to encrypted reasoning (#44863) 2026-08-24 23:43:04 -05:00
Dax Raad 1144ef6c5d feat(www): serve cached markdown documentation 2026-08-25 00:34:34 -04:00
Luke Parker d6deb62379 fix(app): default typography to Inter and IBM Plex Mono (#44876) 2026-08-25 14:30:21 +10:00
93 changed files with 1293 additions and 416 deletions
+10 -1
View File
@@ -135,7 +135,16 @@ jobs:
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) {
// GitHub only populates closingIssuesReferences when a PR targets the repository's
// default branch (dev). PRs targeting other branches like v2 always return totalCount 0.
// Fall back to checking the PR description for closing keywords (e.g. Closes #123).
const body = pr.body || '';
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : body;
const hasBodyIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
const hasLinkedIssue = linkedIssues > 0 || hasBodyIssueRef;
if (!hasLinkedIssue) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
+9
View File
@@ -430,6 +430,7 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@brendonovich/vite-plugin-opencode": "0.1.1",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
@@ -1610,6 +1611,8 @@
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
"@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="],
@@ -6132,6 +6135,8 @@
"@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client": ["@opencode-ai/client@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-beta-18050", "@opencode-ai/schema": "0.0.0-beta-18050" }, "peerDependencies": { "effect": "4.0.0-rc.111", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-zWZv5X23iyx+/mxwiAi18YY/VMjQofTaH7RyKMBt7KL6FmaKAWf9Q05zbQhrLX8DYJD3MOtbjBeQCGLsPUDU8g=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@@ -6956,6 +6961,10 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
"x86_64-linux": "sha256-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
}
}
+74 -12
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -361,6 +361,8 @@ const AnthropicStreamBlock = Schema.Struct({
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
type: Schema.optional(Schema.String),
@@ -371,13 +373,15 @@ const AnthropicStreamDelta = Schema.Struct({
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
@@ -1106,7 +1110,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"]>): LLMEvent | undefined => {
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1133,7 +1137,10 @@ const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult =
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
}
const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const onContentBlockStart = (
state: ParserState,
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
@@ -1224,11 +1231,12 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
state: ParserState,
event: AnthropicEvent,
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
) {
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
@@ -1237,6 +1245,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{
@@ -1249,6 +1258,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
{
...state,
@@ -1301,7 +1311,10 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
@@ -1356,11 +1369,49 @@ const onError = (event: AnthropicEvent) =>
}),
)
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const invalidStreamEvent = (event: AnthropicEvent) =>
Effect.fail(
ProviderShared.eventError(
ADAPTER,
"Invalid anthropic/anthropic-messages stream event",
ProviderShared.encodeJson(event),
),
)
const step = (state: ParserState, event: AnthropicEvent) => {
if (!SSE_EVENTS.has(event.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
if (
event.type !== "content_block_start" &&
event.content_block !== undefined &&
Option.isNone(decodeAnthropicStreamBlock(event.content_block))
)
return invalidStreamEvent(event)
if (
event.type !== "content_block_delta" &&
event.delta !== undefined &&
Option.isNone(decodeAnthropicStreamDelta(event.delta))
)
return invalidStreamEvent(event)
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
@@ -1368,11 +1419,22 @@ const step = (state: ParserState, event: AnthropicEvent) => {
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return onContentBlockDelta(state, { ...event, delta: decoded.value })
}
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_delta") {
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed(onMessageDelta(state, { ...event, delta: decoded.value }))
}
if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
+1 -1
View File
@@ -666,7 +666,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
+1 -1
View File
@@ -659,7 +659,7 @@ const detectZaiToolStream = (
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const cacheKey = ProviderShared.promptCacheKey(request)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
@@ -17,6 +17,7 @@ export const route = Route.make({
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
@@ -207,7 +207,7 @@ export const route = Route.make({
endpoint,
auth,
transport,
defaults: { providerOptions: { store: false } },
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export * as OpenAIResponses from "./openai-responses.js"
+4 -4
View File
@@ -28,10 +28,10 @@ export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
export const promptCacheKey = (request: LLMRequest): string | undefined => {
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
const chars = Array.from(request.promptCacheKey)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
@@ -42,7 +42,61 @@ export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown
try {
return decodeJson(input)
} catch {}
return _parseJSON(input, allowPartial)
const repaired = repairJSON(input)
if (repaired !== input) {
try {
return decodeJson(repaired)
} catch {}
}
try {
return _parseJSON(input, allowPartial)
} catch (error) {
if (repaired !== input) return _parseJSON(repaired, allowPartial)
throw error
}
}
const repairJSON = (input: string) => {
let repaired = ""
let quoted = false
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (!quoted) {
repaired += character
if (character === '"') quoted = true
continue
}
if (character === '"') {
repaired += character
quoted = false
continue
}
if (character === "\\") {
const next = input[index + 1]
if (next === "u" && /^[0-9a-fA-F]{4}$/.test(input.slice(index + 2, index + 6))) {
repaired += input.slice(index, index + 6)
index += 5
continue
}
if (next !== undefined && '"\\/bfnrtu'.includes(next)) {
repaired += `\\${next}`
index++
continue
}
repaired += "\\\\"
continue
}
const code = character.charCodeAt(0)
repaired += code <= 0x1f ? `\\u${code.toString(16).padStart(4, "0")}` : character
}
return repaired
}
const _parseJSON = (jsonString: string, allow: number) => {
@@ -148,7 +202,12 @@ const _parseJSON = (jsonString: string, allow: number) => {
skipBlank()
index++
try {
object[key] = parseAny()
Object.defineProperty(object, key, {
value: parseAny(),
enumerable: true,
configurable: true,
writable: true,
})
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
+23 -25
View File
@@ -1,5 +1,5 @@
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
@@ -59,46 +59,44 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) => {
const input = parsePartialInput(tool.input)
return LLMEvent.toolInputDelta({
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
...(Option.isSome(input) ? { input: input.value } : {}),
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
}
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
}),
Option.getOrElse(
Option.map(parsePartialInput(raw), (input) => input ?? {}),
() => ({}),
),
),
),
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
event,
]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -181,7 +179,7 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and return either a call or a non-executable local input error.
* from state, and recover incomplete local arguments when needed.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
+1 -3
View File
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
import { isRecord } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,12 +115,10 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody
}),
),
+1 -1
View File
@@ -42,7 +42,7 @@ const responsesRoute = Route.make({
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false } },
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const chatRoute = Route.make({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -18,6 +18,21 @@ describe("partial JSON", () => {
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
})
test("repairs invalid escapes and raw control characters", () => {
expect(parse('{"path":"A\\H","text":"first\tsecond"}')).toEqual({
path: "A\\H",
text: "first\tsecond",
})
})
test("preserves prototype keys in partial objects", () => {
const object = parse('{"__proto__":{"safe":true}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
expect(Object.getPrototypeOf(object)).toBe(Object.prototype)
expect(object.__proto__).toEqual({ safe: true })
})
test("controls partial collection values independently", () => {
expect(parse('["', Allow.ARR)).toEqual([])
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
+9 -2
View File
@@ -95,7 +95,11 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
expect(selected.route.defaults.providerOptions).toEqual({
reasoningEffort: "low",
store: true,
include: ["reasoning.encrypted_content"],
})
})
test("maps Anthropic-compatible settings onto the executable model", async () => {
@@ -285,7 +289,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses",
})
expect(responses.route.defaults.providerOptions).toEqual({ store: false })
expect(responses.route.defaults.providerOptions).toEqual({
store: false,
include: ["reasoning.encrypted_content"],
})
})
test("rejects conflicting Vertex auth settings at runtime", async () => {
@@ -770,6 +770,108 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores unknown content block and delta variants", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "future_event", content_block: 42, delta: 42 },
{ type: "content_block_start", index: 0, content_block: { type: "future_block", text: 42 } },
{ type: "content_block_delta", index: 0, delta: { text: "ignored" } },
{ type: "content_block_delta", index: 0, delta: { type: "future_delta", text: 42 } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hidden" } },
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hidden" } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "hidden" } },
{ type: "content_block_stop", index: 0 },
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("rejects malformed recognized content block variants", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: 42 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("rejects malformed recognized content delta variants", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: 42 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("rejects malformed payloads on unrelated stream events", () =>
Effect.gen(function* () {
const events = [
{ type: "message_start", message: { usage: { input_tokens: 1 } }, delta: 42 },
{ type: "content_block_start", index: 0 },
{ type: "content_block_delta", index: 0 },
{ type: "content_block_stop", index: 0, content_block: { type: "text", text: 42 } },
{ type: "message_delta" },
{ type: "message_delta", delta: { stop_reason: 42 } },
{ type: "message_stop", delta: { text: 42 } },
{ type: "error", error: { type: "overloaded_error", message: "busy" }, content_block: 42 },
]
yield* Effect.forEach(events, (event) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
}),
)
it.effect("rejects malformed recognized SSE events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -491,7 +491,7 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits malformed tool input as an unexecuted tool error", () =>
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
@@ -508,10 +508,10 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
id: "tool_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
@@ -192,6 +192,21 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "session_123",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
@@ -52,10 +52,28 @@ describe("Open Responses-compatible route", () => {
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
})
}),
)
it.effect("allows callers to override stateless encrypted reasoning defaults", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", providerOptions: { store: true, include: [] } }),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
@@ -117,6 +117,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
max_output_tokens: 20,
temperature: 0,
@@ -313,7 +314,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.route).toBe("openai-responses")
expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "http-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
expect(prepared.body).toMatchObject({
model: "gpt-4.1-mini",
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
})
}),
)
@@ -385,6 +391,7 @@ describe("OpenAI Responses route", () => {
model: "gpt-4.1-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
store: false,
include: ["reasoning.encrypted_content"],
})
}),
)
@@ -1189,6 +1196,7 @@ describe("OpenAI Responses route", () => {
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
],
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
max_output_tokens: undefined,
temperature: undefined,
@@ -1633,11 +1641,11 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits include when no include is set", () =>
it.effect("requests encrypted reasoning by default", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { store: false } }))
expect(prepared.body.include).toBeUndefined()
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
@@ -1691,6 +1699,21 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "request_cache",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3061,7 +3084,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
it.effect("recovers authoritative incomplete final function arguments", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3087,18 +3110,17 @@ describe("OpenAI Responses route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
expect(response.events.some(LLMEvent.is.toolInputError)).toBeFalse()
}),
)
it.effect("settles malformed function arguments when output_item.added is absent", () =>
it.effect("recovers incomplete function arguments when output_item.added is absent", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3115,10 +3137,10 @@ describe("OpenAI Responses route", () => {
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason.normalized).toBe("tool-calls")
}),
@@ -190,6 +190,21 @@ describe("OpenRouter", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini"),
prompt: "Hello",
promptCacheKey: "session_123",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
@@ -21,6 +21,17 @@ describe("xAI Responses route", () => {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.protocol).toBe("xai-responses")
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
it.effect("allows callers to opt out of encrypted reasoning", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", providerOptions: { include: [] } }))
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toBeUndefined()
}),
)
+40 -16
View File
@@ -59,7 +59,7 @@ describe("ToolStream", () => {
}),
)
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
@@ -72,7 +72,7 @@ describe("ToolStream", () => {
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
@@ -132,7 +132,7 @@ describe("ToolStream", () => {
}),
)
it.effect("finalizes malformed local input as a non-executable tool error", () =>
it.effect("finalizes incomplete local input using the partial JSON parser", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -144,18 +144,46 @@ describe("ToolStream", () => {
expect(finished).toEqual({
tools: {},
events: [
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
},
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "partial" } },
],
})
}),
)
it.effect("preserves valid siblings when one parallel input is malformed", () =>
it.effect("repairs malformed string escapes in final local input", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: '{"path":"A\\H","text":"first\tsecond"}',
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { path: "A\\H", text: "first\tsecond" } },
])
}),
)
it.effect("defaults unrecoverable local input to an empty object", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: "invalid",
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
])
}),
)
it.effect("recovers incomplete input alongside valid parallel tool calls", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
@@ -174,12 +202,8 @@ describe("ToolStream", () => {
events: [
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{
type: "tool-input-error",
id: "call_invalid",
name: "lookup",
raw: '{"query":"partial',
},
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
],
})
}),
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:file:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -59,12 +59,12 @@ test("transitions a streaming shell from writing through command execution", asy
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
@@ -64,6 +64,76 @@ test("transitions shell and question through running error outcomes", async ({ p
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(failed, "patch", "running", { patchText: "Update src/failed.ts" }),
toolPart(
surviving,
"patch",
"running",
{ patchText: "Update src/surviving.ts" },
{
metadata: {
files: [
{
file: "src/surviving.ts",
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
additions: 1,
deletions: 1,
},
],
},
},
),
],
{ completed: false },
),
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.groupIdentity = "preserved"
})
await timeline.send(
partUpdated(
toolPart(failed, "patch", "error", { patchText: "Update src/failed.ts" }, { error: "Patch failed visibly" }),
),
)
const failedRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${failed}"]`),
})
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
+11 -3
View File
@@ -1,7 +1,8 @@
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { Button } from "@opencode-ai/ui/button"
@@ -716,16 +717,23 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
if (queue.editing()) return "steer" as const
return queue.alternate()
})
const [button, setButton] = createSignal<HTMLButtonElement>()
const presence = createAnimatedPresence(action, () => button() ?? null)
return (
<Show when={action()} keyed>
<Show when={presence.present() && presence.value()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
+2 -3
View File
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -49,8 +49,7 @@ export function createHomeController() {
selection: {
value: selection,
set: setSelection,
focusServer: (conn: ServerConnection.Any) =>
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
},
server: {
list: () => servers.visible,
@@ -72,7 +72,7 @@ export function createHomeSessionsController(home: HomeController) {
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
HOME_SESSION_LIMIT,
@@ -256,7 +256,7 @@ export function createHomeSessionsController(home: HomeController) {
data: {
records,
groups,
loading: () => sessionLoad.isLoading,
loading: () => sessionLoad.isPending,
searchRecords: allRecords,
},
session: {
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
<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()}
+19 -19
View File
@@ -1,6 +1,6 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createMemo, For, Index, onCleanup, Show } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -44,6 +44,7 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[]
loading: boolean
showProjectName: boolean
server: ServerConnection.Key
canCreateSession: boolean
@@ -97,22 +98,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -122,7 +121,8 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Suspense
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -164,7 +164,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</Index>
</div>
</Show>
</Suspense>
</Show>
</div>
</section>
)
@@ -421,7 +421,7 @@ export function PromptProjectSelector(props: {
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<Menu.SubContent class="max-h-[224px] min-w-[180px] overflow-y-auto rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
@@ -0,0 +1,21 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
show: () => animation().show,
animate: () => animation().animate,
value: () => animation().value,
}
}
+2 -2
View File
@@ -66,11 +66,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
provider_auth: {},
get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
if (pathQuery.isLoading) return EMPTY
if (pathQuery.isPending) return EMPTY
return pathQuery.data ?? EMPTY
},
get config() {
if (configQuery.isLoading) return {}
if (configQuery.isPending) return {}
return configQuery.data ?? {}
},
get reload() {
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
import createPresence from "solid-presence"
import { createStore } from "solid-js/store"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -479,17 +479,7 @@ function MessageTimelineView(
return row.group.ref.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
(previous) => {
const show = backgroundHintPartID() !== undefined
return { show, animate: previous.animate || previous.show !== show }
},
{ show: backgroundHintPartID() !== undefined, animate: false },
)
const backgroundHintPresence = createPresence({
show: () => backgroundHintVisibility().show,
element: () => backgroundHintRef() ?? null,
})
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
@@ -507,9 +497,9 @@ function MessageTimelineView(
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />
+24 -9
View File
@@ -1,15 +1,30 @@
import { describe, expect, test } from "bun:test"
import { migrateSettings } from "./model"
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
describe("settings migration", () => {
test("keeps steer for existing users without a follow-up preference", () => {
expect(migrateSettings({ general: { autoSave: true } })).toEqual({
general: { autoSave: true, followUpBehavior: "steer" },
})
describe("settings font families", () => {
test("defaults normal text to Inter", () => {
expect(sansDefault).toBe("Inter")
expect(sansFontFamily(undefined)).toStartWith('"Inter", ')
expect(sansFontFamily("")).toStartWith('"Inter", ')
expect(sansFontFamily(" ")).toStartWith('"Inter", ')
})
test("preserves an explicit follow-up preference", () => {
const settings = { general: { followUpBehavior: "queue" } }
expect(migrateSettings(settings)).toBe(settings)
test("keeps custom normal fonts ahead of the default", () => {
expect(sansFontFamily("Custom Sans")).toStartWith('"Custom Sans", "Inter", ')
})
test("defaults monospace text to IBM Plex Mono", () => {
expect(monoDefault).toBe("IBM Plex Mono")
expect(monoFontFamily(undefined)).toStartWith('"IBM Plex Mono", ')
expect(monoFontFamily("")).toStartWith('"IBM Plex Mono", ')
expect(monoFontFamily(" ")).toStartWith('"IBM Plex Mono", ')
})
test("keeps custom monospace fonts ahead of the default", () => {
expect(monoFontFamily("Custom Mono")).toStartWith('"Custom Mono", "IBM Plex Mono", ')
})
test("preserves the separate terminal font default", () => {
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
})
})
+6 -22
View File
@@ -60,12 +60,12 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
'"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const terminalFallback =
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -128,7 +128,7 @@ const defaultSettings: Settings = {
showCustomAgents: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "queue",
followUpBehavior: "steer",
},
appearance: {
fontSize: 14,
@@ -167,10 +167,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings",
gate: false,
init: () => {
const [store, setStore, , ready] = persisted(
{ key: "settings.v3", migrate: migrateSettings },
createStore<Settings>(defaultSettings),
)
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -383,16 +380,3 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}
},
})
export function migrateSettings(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const settings = value as Partial<Settings>
if (settings.general?.followUpBehavior) return value
return {
...settings,
general: {
...settings.general,
followUpBehavior: "steer",
},
}
}
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { openNotificationSession } from "./notification"
test("opens notification sessions through the tab router", () => {
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const tab = { type: "session" as const, server, sessionId: "session-1" }
const calls: string[] = []
const tabs = {
addSessionTab: (input: Omit<typeof tab, "type">) => {
calls.push(`add:${input.sessionId}`)
return tab
},
rememberSessionRoute: (_tab: typeof tab, sessionID: string) => {
calls.push(`route:${sessionID}`)
},
select: (input: Tab) => {
calls.push(`select:${input.type === "session" ? input.sessionId : input.draftID}`)
},
}
openNotificationSession(tabs, server, "session-1")
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
})
@@ -51,6 +51,19 @@ type NotificationIndex = {
}
}
type NotificationTabs = Pick<ReturnType<typeof useTabs>, "addSessionTab" | "rememberSessionRoute" | "select">
export function openNotificationSession(
tabs: NotificationTabs,
server: ServerConnection.Key,
sessionID: string,
) {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
if (tab.type !== "session") return
tabs.rememberSessionRoute(tab, sessionID)
tabs.select(tab)
}
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
@@ -211,11 +224,6 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
return typeof location !== "undefined" && location.pathname === sessionHref(input.key, sessionID)
}
const navigate = (href: string) => {
history.pushState(null, "", href)
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
@@ -237,10 +245,9 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
session: sessionID,
})
const href = sessionHref(input.key, sessionID)
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
navigate(href),
openNotificationSession(tabs, input.key, sessionID),
)
}
})
@@ -274,9 +281,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionHref(input.key, sessionID)
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
)
}
})
}
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>("steer")
const presence = createAnimatedPresence(value, () => null)
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(false)
expect(presence.value()).toBe("steer")
expect(presence.present()).toBe(true)
setValue("queue")
expect(presence.animate()).toBe(false)
expect(presence.value()).toBe("queue")
setValue(undefined)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("queue")
setValue("steer")
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("steer")
dispose()
})
})
test("animates the first appearance when initially hidden", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>()
const presence = createAnimatedPresence(value, () => null)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(false)
expect(presence.present()).toBe(false)
setValue("steer")
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("steer")
dispose()
})
})
+1 -1
View File
@@ -176,7 +176,7 @@ function evaluateTemplate(
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -207,7 +207,7 @@ export const GithubCopilotPlugin = define({
} else {
for (const id of item.models.keys()) {
evt.model.update(item.provider.id, id, (model) => {
model.package = "@ai-sdk/github-copilot"
model.package = Provider.aisdk("@ai-sdk/github-copilot")
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
})
}
+1 -1
View File
@@ -164,7 +164,7 @@ const layer = () =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || (yield* shell.preferred())
const command = input.command || (yield* shell.resolve({ priority: "config" }))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
+7 -7
View File
@@ -30,6 +30,9 @@ export const RETENTION = Duration.days(7)
export const DIRECTORY = "shell"
type Info = Shell.Info
type CreateInput = Shell.CreateInput & {
shell?: string
}
type Active = {
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
@@ -52,9 +55,8 @@ type Active = {
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
@@ -185,8 +187,6 @@ const layer = () =>
return session.info
})
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
@@ -218,7 +218,7 @@ const layer = () =>
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const sessionID = input.metadata?.sessionID
@@ -230,7 +230,7 @@ const layer = () =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* shell.preferred(),
shell: input.shell ?? (yield* shell.resolve({ priority: "config" })),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
@@ -383,7 +383,7 @@ const layer = () =>
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
return Service.of({ create, list, get, wait, timeout, output, remove })
}),
)
+27 -33
View File
@@ -41,8 +41,12 @@ export type Draft = {
configure: (shell: string) => void
}
export type ResolveInput = {
priority: "config" | "compat"
}
export interface Interface extends State.Transformable<Draft> {
readonly preferred: () => Effect.Effect<string>
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
@@ -70,7 +74,7 @@ function meta(file: string) {
return META[name(file)]
}
function ok(file: string) {
function compatible(file: string) {
return meta(file)?.deny !== true
}
@@ -78,7 +82,7 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string, options?: Options, bin?: string) {
function executable(file: string, options?: Options, bin?: string) {
const shell = full(file, options, bin)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
@@ -108,9 +112,9 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file, options, bin)
function select(file: string | undefined, options?: Options, opts?: { compatible?: boolean }, bin?: string) {
if (file && (!opts?.compatible || compatible(file))) {
const shell = executable(file, options, bin)
if (shell) return shell
}
if (process.platform === "win32") return win(options, bin)[0]
@@ -151,8 +155,8 @@ function info(file: string, options?: Options, bin?: string): Item {
const n = name(item)
return {
path: item,
name: resolve(n, options, bin) ? n : item,
acceptable: ok(item),
name: executable(n, options, bin) ? n : item,
acceptable: compatible(item),
}
}
@@ -163,38 +167,28 @@ export function args(file: string, command: string) {
return ["-c", command]
}
let defaultPreferred: { bin?: string; value: string } | undefined
let defaultAcceptable: { bin?: string; value: string } | undefined
let defaultConfigured: { bin?: string; value: string } | undefined
let defaultCompatible: { bin?: string; value: string } | undefined
export function preferred(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, undefined, bin)
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
const cached = defaultPreferred
export function resolve(input: ResolveInput, configShell?: string, options?: Options, bin?: string) {
const filter = input.priority === "compat" ? { compatible: true } : undefined
if (configShell) return select(configShell, options, filter, bin)
if (options?.gitbash) return select(process.env.SHELL, options, filter, bin)
const cached = input.priority === "compat" ? defaultCompatible : defaultConfigured
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
defaultPreferred = { bin, value }
const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
if (input.priority === "compat") defaultCompatible = { bin, value }
if (input.priority === "config") defaultConfigured = { bin, value }
return value
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, { acceptable: true }, bin)
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
const cached = defaultAcceptable
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
defaultAcceptable = { bin, value }
return value
}
acceptable.reset = () => {
defaultAcceptable = undefined
resolve.reset = () => {
defaultConfigured = undefined
defaultCompatible = undefined
}
export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
return shells.filter((shell) => executable(shell, options, bin)).map((shell) => info(shell, options, bin))
}
const layer = (options?: Options) =>
@@ -214,7 +208,7 @@ const layer = (options?: Options) =>
return Service.of({
transform: state.transform,
reload: state.reload,
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)),
})
}),
)
+5 -1
View File
@@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
@@ -109,6 +110,8 @@ export const Plugin = {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const compatibleShell = shellSelect.resolve({ priority: "compat" })
const permission = yield* Permission.Service
const config = yield* Config.Service
@@ -185,6 +188,7 @@ export const Plugin = {
command: input.command,
cwd: input.workdir,
timeout,
shell: yield* compatibleShell,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
@@ -340,7 +344,7 @@ export const Plugin = {
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(yield* shell.name())
tool.description = description(ShellSelect.name(yield* compatibleShell))
}),
)
}),
+15 -6
View File
@@ -2,6 +2,7 @@ import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
import { $ZodType, toJSONSchema } from "zod/v4/core"
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
@@ -129,13 +130,15 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
)
}
const isStandardSchema = (
schema: Tool.ValueSchema<any>,
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> =>
const isStandardSchema = (schema: Tool.ValueSchema<any>): schema is StandardSchemaV1<any, any> =>
typeof schema === "object" && schema !== null && "~standard" in schema
const isStandardJSONSchema = (
schema: StandardSchemaV1<any, any>,
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> => "jsonSchema" in schema["~standard"]
const validateStandard = (
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
schema: StandardSchemaV1<any, any>,
value: unknown,
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
Effect.gen(function* () {
@@ -150,15 +153,21 @@ const validateStandard = (
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
if (schema === undefined || schema === null) return {}
if (isStandardSchema(schema)) return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" })
if (isStandardSchema(schema)) return standardJsonSchema(schema, "input")
return Schema.isSchema(schema) ? toJsonSchema(schema) : schema
}
const outputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
if (isStandardSchema(schema)) return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" })
if (isStandardSchema(schema)) return standardJsonSchema(schema, "output")
return Schema.isSchema(schema) ? toJsonSchema(schema) : schema
}
const standardJsonSchema = (schema: StandardSchemaV1<any, any>, io: "input" | "output"): JsonSchema.JsonSchema => {
if (isStandardJSONSchema(schema)) return schema["~standard"].jsonSchema[io]({ target: "draft-2020-12" })
if (schema instanceof $ZodType) return toJSONSchema(schema, { target: "draft-2020-12", io })
throw new Error(`Schema vendor "${schema["~standard"].vendor}" does not support JSON Schema conversion`)
}
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
const document = Schema.toJsonSchemaDocument(schema)
// Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it
+1 -1
View File
@@ -33,7 +33,7 @@ import { host } from "../plugin/host"
const shellLayer = Layer.succeed(
ShellSelect.Service,
ShellSelect.Service.of({
preferred: () => Effect.succeed("sh"),
resolve: () => Effect.succeed("sh"),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
}),
+2
View File
@@ -604,6 +604,7 @@ describe("Config", () => {
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
@@ -616,6 +617,7 @@ describe("Config", () => {
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
+2 -2
View File
@@ -24,12 +24,12 @@ describe("ConfigShellPlugin.Plugin", () => {
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
expect(yield* shell.preferred()).toBe(configured)
expect(yield* shell.resolve({ priority: "config" })).toBe(configured)
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* shell.preferred()) !== configured) return
if ((yield* shell.resolve({ priority: "config" })) !== configured) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
@@ -36,6 +36,18 @@ test("defensively syncs advertised Copilot models", async () => {
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
},
},
{
model_picker_enabled: true,
id: "claude-sonnet",
name: "Claude Sonnet",
version: "claude-sonnet-2026-06-01",
supported_endpoints: ["/v1/messages"],
capabilities: {
family: "claude",
limits: { max_output_tokens: 16384, max_prompt_tokens: 180000 },
supports: { tool_calls: true },
},
},
{
model_picker_enabled: true,
id: "vision-only",
@@ -85,6 +97,7 @@ test("defensively syncs advertised Copilot models", async () => {
const model = models.get(Model.ID.make("gpt-5"))
expect(model?.name).toBe("GPT-5 local")
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/github-copilot"))
expect(model?.settings).toMatchObject({ baseURL: server.url.origin, endpoint: "responses" })
expect(model?.cost[0]).toMatchObject({ input: 0, output: 0, cache: { read: 0, write: 0 } })
expect(model?.variants.map((variant) => variant.id)).toEqual([
@@ -92,6 +105,11 @@ test("defensively syncs advertised Copilot models", async () => {
Model.VariantID.make("high"),
])
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
expect(models.get(Model.ID.make("claude-sonnet"))?.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
expect(models.get(Model.ID.make("claude-sonnet"))?.settings).toMatchObject({
baseURL: `${server.url.origin}/v1`,
endpoint: "messages",
})
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
expect(models.has(Model.ID.make("stale"))).toBe(false)
+5 -1
View File
@@ -1080,7 +1080,11 @@ describe("ModelResolver", () => {
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.defaults.providerOptions).toEqual({ reasoning: { effort: "high" } })
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", store: false })
expect(xai.route.defaults.providerOptions).toEqual({
reasoningEffort: "high",
store: false,
include: ["reasoning.encrypted_content"],
})
expect(bedrock.route.id).toBe("bedrock-converse")
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
+25
View File
@@ -234,6 +234,31 @@ describe("ModelsDev Service", () => {
}),
)
it.live("normalizes provider and model AI SDK packages from models.dev", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, {
acme: {
...fixture.acme,
models: {
"acme-1": {
...fixture.acme.models["acme-1"],
provider: { npm: "@ai-sdk/openai" },
},
},
},
})
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
expect(result[0]?.info.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
expect(result[0]?.models[0]?.package).toBe(Provider.aisdk("@ai-sdk/openai"))
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
@@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -198,16 +199,23 @@ describe("GithubCopilotPlugin", () => {
it.effect("rewrites models.dev fallback models to the GitHub Copilot package", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const aisdk = yield* AISDK.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(Provider.ID.githubCopilot, () => {})
catalog.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"), (model) => {
model.package = "@ai-sdk/openai-compatible"
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"))).package).toBe(
"@ai-sdk/github-copilot",
)
const fallback = required(yield* catalog.model.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol")))
expect(fallback.package).toBe(Provider.aisdk("@ai-sdk/github-copilot"))
const resolved = yield* ModelResolver.fromCatalogModel(fallback, undefined, {
loadPackage: () => Effect.die("Copilot must not load a native provider package"),
loadAISDK: (model) => aisdk.model(model),
})
expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/github-copilot")
expect(resolved.route.providerMetadataKey).toBe("copilot")
}),
)
@@ -247,6 +247,10 @@ describe("OpencodePlugin", () => {
cost: { input: 1, output: 2, cache_read: 0.1 },
limit: { context: 1000, output: 100 },
},
override: {
name: "Override",
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` },
},
disabled: { name: "Disabled", status: "deprecated" },
},
},
@@ -308,6 +312,9 @@ describe("OpencodePlugin", () => {
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
headers: { "x-org-id": "org" },
})
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
expect(model.variants).toEqual([
{
id: Model.VariantID.make("custom"),
@@ -11,6 +11,7 @@ import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
import { testEffect } from "./lib/effect"
const imageStore = Layer.mock(Image.Service, {
@@ -556,6 +557,39 @@ describe("Tool", () => {
}),
)
it.effect("registers, advertises, and executes a Zod tool", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(
service,
{
zod: {
name: "zod",
description: "Increment a parsed number",
input: z.object({ count: z.string().transform(Number) }),
output: z.object({ count: z.number() }),
execute: ({ count }) => Effect.succeed({ output: { count: count + 1 } }),
},
},
{ codemode: false },
)
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.find((tool) => tool.name === "zod")?.inputSchema).toMatchObject({
type: "object",
properties: { count: { type: "string" } },
required: ["count"],
})
expect(
yield* snapshot.execute({
sessionID,
...identity,
call: { type: "tool-call", id: "call-zod", name: "zod", input: { count: "41" } },
}),
).toMatchObject({ output: { count: 42 } })
}),
)
it.effect("executes the tool advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+18 -20
View File
@@ -8,15 +8,13 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise<voi
const prev = process.env.SHELL
if (shell === undefined) delete process.env.SHELL
else process.env.SHELL = shell
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
ShellSelect.resolve.reset()
try {
await fn()
} finally {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
ShellSelect.resolve.reset()
}
}
@@ -36,16 +34,16 @@ describe("shell", () => {
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = ShellSelect.preferred()
const acceptable = ShellSelect.acceptable()
expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
const configured = ShellSelect.resolve({ priority: "config" })
const compatible = ShellSelect.resolve({ priority: "compat" })
expect(ShellSelect.resolve({ priority: "config" }, "opencode-missing-shell")).toBe(configured)
expect(ShellSelect.resolve({ priority: "compat" }, "opencode-missing-shell")).toBe(compatible)
})
})
test("falls back for terminal-only acceptable shells", () => {
expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
test("falls back for terminal-only shells when compatibility is required", () => {
expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }, "fish"))).not.toBe("fish")
expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }, "nu"))).not.toBe("nu")
})
test("builds command args per shell family", () => {
@@ -65,14 +63,14 @@ describe("shell", () => {
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu")
expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }))).not.toBe("nu")
})
})
test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => {
expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell))
expect(ShellSelect.resolve({ priority: "config" })).toBe(FSUtil.windowsPath(shell))
})
})
@@ -80,19 +78,19 @@ describe("shell", () => {
const bash = ShellSelect.gitbash()
if (!bash) return
await withShell("/usr/bin/bash", async () => {
expect(ShellSelect.acceptable()).toBe(bash)
expect(ShellSelect.preferred()).toBe(bash)
expect(ShellSelect.resolve({ priority: "compat" })).toBe(bash)
expect(ShellSelect.resolve({ priority: "config" })).toBe(bash)
})
})
test("resolves bare bash to Git Bash before PATH", async () => {
const bash = ShellSelect.gitbash()
if (!bash) return
expect(ShellSelect.acceptable("bash")).toBe(bash)
expect(ShellSelect.preferred("bash")).toBe(bash)
expect(ShellSelect.resolve({ priority: "compat" }, "bash")).toBe(bash)
expect(ShellSelect.resolve({ priority: "config" }, "bash")).toBe(bash)
await withShell("bash", async () => {
expect(ShellSelect.acceptable()).toBe(bash)
expect(ShellSelect.preferred()).toBe(bash)
expect(ShellSelect.resolve({ priority: "compat" })).toBe(bash)
expect(ShellSelect.resolve({ priority: "config" })).toBe(bash)
})
})
@@ -100,7 +98,7 @@ describe("shell", () => {
const shell = which("pwsh") || which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(ShellSelect.preferred()).toBe(shell)
expect(ShellSelect.resolve({ priority: "config" })).toBe(shell)
})
})
}
+38
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { z } from "zod"
import type { Info } from "@opencode-ai/schema/tool"
import { Tool } from "../src/tool"
import { definition, execute } from "../src/tool/runtime"
@@ -139,6 +140,43 @@ test("portable schemas validate and describe typed tools", async () => {
expect(result.output).toBe("42")
})
test("Zod schemas validate, transform, and describe typed tools", async () => {
const tool: Info = {
name: "zod",
description: "Zod tool",
input: z.object({ count: z.string().transform(Number) }),
output: z.object({ count: z.number() }),
execute: ({ count }) => Effect.succeed({ output: { count: count + 1 } }),
}
expect(definition(tool)).toEqual({
name: "zod",
description: "Zod tool",
inputSchema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { count: { type: "string" } },
required: ["count"],
},
outputSchema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
additionalProperties: false,
},
})
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
output: { count: 42 },
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("portable schema failures become tool failures", async () => {
const input = {
"~standard": {
+2
View File
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -136,6 +137,7 @@ const shellPluginSupervisor = makeLocationNode({
Permission.node,
PluginRuntime.node,
Shell.node,
ShellSelect.node,
Tool.node,
],
})
+2 -1
View File
@@ -10,6 +10,7 @@ const channel = (() => {
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const appPlugin = (await import("@opencode-ai/app/vite")).default
const picker = (await import("@brendonovich/vite-plugin-opencode")).default()
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
@@ -80,7 +81,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
plugins: [appPlugin, sentry],
plugins: [{ ...picker, transformIndexHtml: undefined }, appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
build: {
+1
View File
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@brendonovich/vite-plugin-opencode": "0.1.1",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
+2
View File
@@ -1,3 +1,5 @@
declare module "virtual:vite-opencode-picker/client"
interface ImportMetaEnv {
readonly OPENCODE_CHANNEL: string
readonly OPENCODE_VERSION?: string
+3 -64
View File
@@ -2,7 +2,9 @@ const resizeLoopWarning = "ResizeObserver loop completed with undelivered notifi
if (import.meta.env.DEV) {
installConsoleStacks()
installResizeObserverStacks()
window.addEventListener("error", (event) => {
if (event.message === resizeLoopWarning) event.preventDefault()
})
}
function installConsoleStacks() {
@@ -10,60 +12,6 @@ function installConsoleStacks() {
console.error = tracedConsole(console.error.bind(console), "Console error")
}
function installResizeObserverStacks() {
if (typeof ResizeObserver !== "function") return
const NativeResizeObserver = ResizeObserver
type ResizeTrace = {
created: string
last?: { at: number; targets: Element[] }
}
const observers = new Set<ResizeTrace>()
class TracedResizeObserver extends NativeResizeObserver {
private readonly trace: ResizeTrace
constructor(callback: ResizeObserverCallback) {
const trace: ResizeTrace = {
created: diagnosticStack("ResizeObserver created"),
}
super((entries, observer) => {
trace.last = { at: performance.now(), targets: entries.map((entry) => entry.target) }
callback(entries, observer)
})
this.trace = trace
observers.add(trace)
}
override disconnect() {
observers.delete(this.trace)
super.disconnect()
}
}
globalThis.ResizeObserver = TracedResizeObserver
window.addEventListener("error", (event) => {
if (event.message !== resizeLoopWarning) return
const now = performance.now()
const active = [...observers]
.flatMap((observer) => {
if (!observer.last || now - observer.last.at >= 100) return []
return [{ ...observer, last: observer.last }]
})
.sort((a, b) => b.last.at - a.last.at)
.slice(0, 5)
const detail = active.length
? active
.map(
(observer, index) =>
`Recent ResizeObserver ${index + 1}; targets: ${observer.last.targets.map(describeElement).join(", ") || "none"}\n${observer.created}`,
)
.join("\n")
: "No ResizeObserver callback was recorded in the previous 100 ms."
console.warn(`[renderer diagnostics] ${resizeLoopWarning}\n${detail}`)
})
}
function tracedConsole(write: (...args: unknown[]) => void, label: string) {
const pending = new Map<string, { count: number }>()
return (...args: unknown[]) => {
@@ -85,15 +33,6 @@ function tracedConsole(write: (...args: unknown[]) => void, label: string) {
}
}
function describeElement(element: Element) {
const id = element.id ? `#${element.id}` : ""
const classes = [...element.classList]
.slice(0, 3)
.map((name) => `.${name}`)
.join("")
return `${element.tagName.toLowerCase()}${id}${classes}`
}
function diagnosticStack(label: string) {
return new Error(label).stack ?? label
}
+2
View File
@@ -20,3 +20,5 @@ startDesktopMenu(api)
startDeepLinks(api)
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
if (import.meta.env.DEV) void import("virtual:vite-opencode-picker/client")
+2 -5
View File
@@ -1,7 +1,7 @@
export * as Tool from "./tool.js"
import { Effect, JsonSchema, Schema } from "effect"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import type { Agent } from "./agent.js"
import type { Session } from "./session.js"
import type { SessionMessage } from "./session-message.js"
@@ -36,10 +36,7 @@ export type Options = BaseOptions &
}
)
export type ValueSchema<A = unknown> =
| Schema.Codec<A, any>
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
| JsonSchema.JsonSchema
export type ValueSchema<A = unknown> = Schema.Codec<A, any> | StandardSchemaV1<any, A> | JsonSchema.JsonSchema
type InputValue<S> = 0 extends 1 & S
? any
@@ -24,6 +24,17 @@ const patch = (key: string, partIDs: string[], userMessageID = "user-1") =>
previousAssistantPart: false,
})
const part = (key: string, partID: string) =>
new TimelineRow.AssistantPart({
userMessageID: "user-1",
group: {
key,
type: "part",
ref: { messageID: "assistant-1", partID },
} satisfies PartGroup,
previousAssistantPart: false,
})
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID })
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
@@ -33,49 +44,63 @@ describe("reuseTimelineRows", () => {
name: "reuses an unchanged context group",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:context:a"],
expected: ["assistant-part:context:context:a"],
reused: [[0, 0]],
},
{
name: "preserves the group key when a member is appended",
previous: [context("context:a", ["a"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:context:a"],
expected: ["assistant-part:context:context:a"],
reused: [],
},
{
name: "preserves a patch group key when a member is appended",
previous: [patch("patch:a", ["a"])],
rows: [patch("patch:a", ["a", "b"])],
expected: ["assistant-part:patch:a"],
expected: ["assistant-part:file:patch:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"])],
expected: ["assistant-part:context:a"],
expected: ["assistant-part:context:context:a"],
reused: [],
},
{
name: "lets only the natural owner retain an old key after a split",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
expected: ["assistant-part:context:a", "assistant-part:context:b"],
expected: ["assistant-part:context:context:a", "assistant-part:context:context:b"],
reused: [],
},
{
name: "preserves the file group identity when its first member becomes standalone",
previous: [patch("part:a", ["a", "b"])],
rows: [part("part:a", "a"), patch("part:b", ["b"])],
expected: ["assistant-part:part:part:a", "assistant-part:file:part:a"],
reused: [],
},
{
name: "preserves the file group identity before a later standalone member",
previous: [patch("part:a", ["a", "b"])],
rows: [patch("part:b", ["b"]), part("part:a", "a")],
expected: ["assistant-part:file:part:a", "assistant-part:part:part:a"],
reused: [],
},
{
name: "chooses the earliest prior key when groups merge",
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
rows: [context("context:b", ["b", "a"])],
expected: ["assistant-part:context:a"],
expected: ["assistant-part:context:context:a"],
reused: [],
},
{
name: "reserves an old key for its natural owner when two new groups compete",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
expected: ["assistant-part:context:b", "assistant-part:context:a"],
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a"],
reused: [],
},
{
@@ -84,14 +109,14 @@ describe("reuseTimelineRows", () => {
name: "reuses context identity when the same parts move to another user message",
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
rows: [context("context:b", ["b"], { userMessageID: "user-2" })],
expected: ["assistant-part:context:a"],
expected: ["assistant-part:context:context:a"],
reused: [],
},
{
name: "does not reuse context identity across assistant messages",
previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })],
rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })],
expected: ["assistant-part:context:assistant-2:a"],
expected: ["assistant-part:context:context:assistant-2:a"],
reused: [],
},
{
@@ -105,7 +130,7 @@ describe("reuseTimelineRows", () => {
name: "does not create accidental key collisions",
previous: [context("context:a", ["a", "b", "c"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
expected: ["assistant-part:context:b", "assistant-part:context:a", "assistant-part:context:c"],
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a", "assistant-part:context:context:c"],
reused: [],
},
])("$name", ({ previous, rows, expected, reused }) => {
@@ -29,10 +29,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:part:msg_2:msg_2:text:0",
"assistant-part:part:part:msg_2:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:part:msg_4:msg_4:reasoning:0",
"assistant-part:part:part:msg_4:msg_4:reasoning:0",
])
})
@@ -79,7 +79,7 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([
"notice:msg_notice",
"assistant-part:part:msg_assistant:msg_assistant:text:0",
"assistant-part:part:part:msg_assistant:msg_assistant:text:0",
])
})
@@ -140,10 +140,10 @@ describe("current session timeline rows", () => {
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"notice:msg_agent",
"assistant-part:part:msg_assistant_1:msg_assistant_1:text:0",
"assistant-part:part:part:msg_assistant_1:msg_assistant_1:text:0",
"notice:msg_background",
"notice:msg_model",
"assistant-part:part:msg_assistant_2:msg_assistant_2:text:0",
"assistant-part:part:part:msg_assistant_2:msg_assistant_2:text:0",
"notice:msg_restart",
"notice:msg_skill",
"notice:msg_compaction",
@@ -518,9 +518,9 @@ describe("current session timeline rows", () => {
expect(keys).toEqual([
"user-message:msg_user",
"assistant-part:context:msg_assistant_1:tool_0",
"assistant-part:part:msg_assistant_2:tool_0",
"assistant-part:context:msg_assistant_3:tool_0",
"assistant-part:context:context:msg_assistant_1:tool_0",
"assistant-part:part:part:msg_assistant_2:tool_0",
"assistant-part:context:context:msg_assistant_3:tool_0",
])
})
@@ -92,7 +92,7 @@ export namespace TimelineRow {
// and its rows regroup under the real user message once older history loads.
// The group key already carries the owning message and part IDs.
case "AssistantPart":
return `assistant-part:${row.group.key}`
return `assistant-part:${row.group.type}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "Error":
+2
View File
@@ -716,6 +716,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
const model = local.model.current()
const current =
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -729,6 +730,7 @@ function App(props: { pair?: DialogPairCredentials }) {
location.error?.location,
),
})
if (model) local.model.set(model)
dialog.clear()
},
},
+7 -1
View File
@@ -329,7 +329,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
usePaste((event) => {
if (keymap.mode.current() !== FORM_MODE) return
if (!pasteCustom(stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))) return
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
if (store.editing && renderer.currentFocusedEditor === textarea) {
textarea.insertText(value)
event.preventDefault()
return
}
if (!pasteCustom(value)) return
event.preventDefault()
})
+67
View File
@@ -296,6 +296,73 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test("new session inherits the active session model", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "session-model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const calls = createFetch((url) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/model")
return json({
location,
data: [
{ id: "home-model", providerID: "provider", name: "Home Model", variants: [] },
{ id: "session-model", providerID: "provider", name: "Session Model", variants: [] },
],
})
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await setup.waitForFrame((frame) => frame.includes("Session Model"))
await setup.mockInput.typeText("/new")
setup.mockInput.pressEnter()
await Bun.sleep(50)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain("Session Model")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("keeps the prompt display stable while a new location catalog loads", async () => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
+29
View File
@@ -310,6 +310,35 @@ test("pasting on a custom choice opens its editor without submitting", async ()
}
})
test("pasting in an active custom editor inserts at the cursor", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
])
try {
prompt.app.mockInput.pressArrow("down")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
await prompt.app.mockInput.typeText("prodwest")
const editor = prompt.app.renderer.currentFocusedEditor
if (editor) editor.cursorOffset = 4
await prompt.app.mockInput.pasteBracketedText("uction ")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
await prompt.app.mockInput.typeText("!")
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("production !west")
expect(prompt.replies).toEqual([])
} finally {
prompt.app.renderer.destroy()
}
})
test("clipboard shortcut opens a custom choice editor without submitting", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
@@ -199,8 +199,9 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
await app.mockMouse.click(22, 0)
await app.waitForFrame((frame) => items().length === 3 && Array.from(frame.split("\n")[0] ?? "")[22] === "✕")
await app.mockMouse.moveTo(0, 1)
await app.mockMouse.moveTo(20, 0)
await app.mockMouse.moveTo(0, 2, { delayMs: 10 })
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[22] !== "✕")
await app.mockMouse.moveTo(20, 0, { delayMs: 10 })
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[20] === "✕", { maxPasses: 100 })
expect(Array.from(app.captureCharFrame().split("\n")[0] ?? "")[22]).not.toBe("✕")
} finally {
+2 -2
View File
@@ -89,10 +89,10 @@ function init() {
setClosing = setClosingSignal
return (
<Kobalte
modal={stack().length <= layer + 1}
modal={stack().at(-1)?.id === id}
open={!closing()}
onOpenChange={(open: boolean) => {
if (open) return
if (open || stack().at(-1)?.id !== id) return
close(id)
}}
>
+2 -2
View File
@@ -1,8 +1,8 @@
:root {
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-family-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-family-sans--font-feature-settings: normal;
--font-family-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-family-mono--font-feature-settings: normal;
--font-size-small: 13px;
+3 -3
View File
@@ -129,9 +129,9 @@
--v2-illustration-illustration-layer-02: var(--v2-grey-400);
--v2-illustration-illustration-layer-03: var(--v2-grey-500);
--font-family-text: "Inter", sans-serif;
--v2-font-family-sans: "Inter", sans-serif;
--v2-font-family-code: "IBM Plex Mono", var(--font-family-mono);
--font-family-text: var(--font-family-sans);
--v2-font-family-sans: var(--font-family-sans);
--v2-font-family-code: var(--font-family-mono);
--line-height-tight: 12px;
--line-height-compact: 16px;
--line-height-base: 20px;
+7 -5
View File
@@ -1,5 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#131010"></rect>
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 613 B

+4 -1
View File
@@ -1,5 +1,8 @@
import { cp } from "node:fs/promises"
import config from "../astro.config"
const base = config.base?.replace(/\/$/, "") ?? ""
const docs = `dist/client${base}/docs`
await Bun.$`pagefind --site ${`dist/client${base}/docs`}`
await cp(`dist/client${base}/docs-index`, docs, { recursive: true })
await Bun.$`pagefind --site ${docs}`
+15 -4
View File
@@ -1,10 +1,21 @@
export {}
import { rm } from "node:fs/promises"
import path from "node:path"
import astro from "../astro.config"
const path = "dist/server/wrangler.json"
const config = await Bun.file(path).json()
const base = astro.base?.replace(/\/$/, "") ?? ""
const snapshots = `dist/client${base}/docs-index`
await Promise.all(
(await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: snapshots }))).map((file) =>
rm(path.join(`dist/client${base}/docs`, file)),
),
)
await rm(snapshots, { recursive: true })
const config = await Bun.file("dist/server/wrangler.json").json()
delete config.kv_namespaces
delete config.images
delete config.previews
await Bun.write(path, JSON.stringify(config))
await Bun.write("dist/server/wrangler.json", JSON.stringify(config))
@@ -0,0 +1,25 @@
---
import { render, type CollectionEntry } from "astro:content"
import Callout from "./Callout.astro"
import Card from "./Card.astro"
import CardGroup from "./CardGroup.astro"
import CodeBlock from "./CodeBlock.astro"
import DocsLayout from "../layouts/DocsLayout.astro"
interface Props {
entry: CollectionEntry<"docs">
}
const entry = Astro.props.entry
const rendered = await render(entry)
---
<DocsLayout
title={entry.data.title}
description={entry.data.description}
currentSlug={entry.id}
headings={rendered.headings}
showTableOfContents={entry.data.tableOfContents !== false}
>
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock }} />
</DocsLayout>
+28 -1
View File
@@ -54,7 +54,18 @@ const canonical = new URL(Astro.url.pathname, "https://opencode.ai").href
</aside>
<main id="docs-content">
<article class="prose" data-pagefind-body data-pagefind-meta={`title:${Astro.props.title}`}>
<h1>{Astro.props.title}</h1>
<h1>
{Astro.props.title}
<button type="button" aria-label="Copy page as Markdown" data-copy-markdown data-pagefind-ignore>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="8" y="8" width="13" height="13" rx="2" />
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
</svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="m5 12 4 4L19 6" />
</svg>
</button>
</h1>
<slot />
</article>
</main>
@@ -81,5 +92,21 @@ const canonical = new URL(Astro.url.pathname, "https://opencode.ai").href
addHeadingAnchors()
document.addEventListener("astro:page-load", addHeadingAnchors)
</script>
<script>
document.addEventListener("click", async (event) => {
if (!(event.target instanceof Element)) return
const button = event.target.closest<HTMLButtonElement>("[data-copy-markdown]")
if (!button) return
button.dataset.copied = "true"
button.setAttribute("aria-label", "Copied")
window.setTimeout(() => {
delete button.dataset.copied
button.setAttribute("aria-label", "Copy page as Markdown")
}, 1500)
const response = await fetch(window.location.href, { headers: { Accept: "text/markdown" } })
if (!response.ok) return
await navigator.clipboard.writeText(await response.text())
})
</script>
</body>
</html>
+29 -2
View File
@@ -158,8 +158,7 @@ a:hover {
color: var(--foreground);
}
.site-header nav a[aria-current="page"] {
border-bottom: 1px solid;
.site-header a:hover {
text-decoration: none;
}
@@ -788,7 +787,11 @@ main {
}
.prose h1 {
display: flex;
margin: 0 0 3rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 1.875rem;
line-height: 1.2;
}
@@ -993,6 +996,30 @@ main {
font-size: 0.75rem;
}
.prose h1 button {
display: inline-flex;
padding: 0;
flex-shrink: 0;
cursor: pointer;
border: 0;
background: transparent;
color: var(--muted);
}
.prose h1 button:hover,
.prose h1 button[data-copied] {
color: var(--foreground);
}
.prose h1 button svg:last-child,
.prose h1 button[data-copied] svg:first-child {
display: none;
}
.prose h1 button[data-copied] svg:last-child {
display: block;
}
.search-dialog {
width: min(42rem, calc(100% - 2rem));
max-height: min(36rem, calc(100vh - 4rem));
@@ -0,0 +1,21 @@
---
import { getCollection, type CollectionEntry } from "astro:content"
import DocsPage from "../../docs/components/DocsPage.astro"
interface Props {
entry: CollectionEntry<"docs">
}
export async function getStaticPaths() {
return (await getCollection("docs")).map((entry) => ({
params: {
slug: entry.id === "index" ? undefined : entry.id.replace(/\/index$/, ""),
},
props: { entry },
}))
}
export const prerender = true
---
<DocsPage entry={Astro.props.entry} />
+18 -28
View File
@@ -1,36 +1,26 @@
---
import { getCollection, render, type CollectionEntry } from "astro:content"
import Callout from "../../docs/components/Callout.astro"
import Card from "../../docs/components/Card.astro"
import CardGroup from "../../docs/components/CardGroup.astro"
import CodeBlock from "../../docs/components/CodeBlock.astro"
import DocsLayout from "../../docs/layouts/DocsLayout.astro"
import { getEntry } from "astro:content"
import DocsPage from "../../docs/components/DocsPage.astro"
interface Props {
entry: CollectionEntry<"docs">
export const prerender = false
const slug = Astro.params.slug || "index"
const entry = (await getEntry("docs", slug)) ?? (await getEntry("docs", `${slug}/index`))
if (!entry) return new Response("Not found", { status: 404, headers: { "Cache-Control": "no-store" } })
const headers = {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
Vary: "Accept",
}
export async function getStaticPaths() {
return (await getCollection("docs")).map((entry) => ({
params: {
slug: entry.id === "index" ? undefined : entry.id.replace(/\/index$/, ""),
},
props: { entry },
}))
const accept = Astro.request.headers.get("accept") ?? ""
if (accept.includes("text/markdown") || accept.includes("text/x-markdown")) {
return new Response(`# ${entry.data.title}\n\n${entry.body ?? ""}`, {
headers: { ...headers, "Content-Type": "text/markdown; charset=utf-8" },
})
}
export const prerender = true
const entry = Astro.props.entry
const rendered = await render(entry)
Object.entries(headers).forEach(([name, value]) => Astro.response.headers.set(name, value))
---
<DocsLayout
title={entry.data.title}
description={entry.data.description}
currentSlug={entry.id}
headings={rendered.headings}
showTableOfContents={entry.data.tableOfContents !== false}
>
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock }} />
</DocsLayout>
<DocsPage entry={entry} />
+9 -1
View File
@@ -1,5 +1,13 @@
---
export const prerender = false
return fetch("https://raw.githubusercontent.com/anomalyco/opencode/v2/install")
const response = await fetch("https://raw.githubusercontent.com/anomalyco/opencode/v2/install")
const headers = new Headers(response.headers)
headers.set("Cache-Control", "no-store")
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
---
+3
View File
@@ -4,6 +4,9 @@
"compatibility_date": "2026-07-25",
"workers_dev": false,
"preview_urls": false,
"cache": {
"enabled": true,
},
"assets": {
"binding": "ASSETS",
"directory": "./dist/client",