mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 00:16:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc50b15111 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.1
|
||||
bun-version: 1.4.0
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
|
||||
@@ -1065,7 +1065,6 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -1103,7 +1102,7 @@
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
@@ -3033,7 +3032,7 @@
|
||||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
|
||||
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
||||
|
||||
@@ -3485,7 +3484,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -6341,6 +6340,10 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
@@ -6919,6 +6922,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
|
||||
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
|
||||
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
|
||||
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
|
||||
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
|
||||
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
|
||||
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
|
||||
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
@@ -43,7 +43,7 @@
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
@@ -159,7 +159,6 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
+1
-38
@@ -247,8 +247,6 @@ it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
@@ -261,7 +259,7 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
|
||||
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
|
||||
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
@@ -281,41 +279,6 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Streamed checkpoint compaction
|
||||
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
|
||||
|
||||
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
|
||||
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
|
||||
### Advanced: in-band compaction
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives and provider notifications carry no response state and may precede response.created.
|
||||
if (!event.type.startsWith("response.")) return { type: "frame", frame }
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -42,7 +42,6 @@ const canonical = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
||||
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
|
||||
return `{${Object.keys(value)
|
||||
.filter((key) => value[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
|
||||
.join(",")}}`
|
||||
@@ -58,12 +57,7 @@ const comparable = (value: unknown) => {
|
||||
if (value.type === "message" && value.role === "assistant")
|
||||
return {
|
||||
role: "assistant",
|
||||
// Annotations and logprobs describe the response, not the text replayed in model input.
|
||||
content: Array.isArray(value.content)
|
||||
? value.content.map((part) =>
|
||||
ProviderShared.isRecord(part) && part.type === "output_text" ? { type: part.type, text: part.text } : part,
|
||||
)
|
||||
: value.content,
|
||||
content: value.content,
|
||||
...(value.phase === undefined ? {} : { phase: value.phase }),
|
||||
}
|
||||
if (value.type === "function_call")
|
||||
@@ -127,7 +121,7 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
let output: unknown[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
@@ -155,12 +149,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
if (
|
||||
Array.isArray(request.input) &&
|
||||
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
|
||||
)
|
||||
return observation
|
||||
const responseID = event.response?.id
|
||||
if (!responseID || responseID.trim().length === 0) return observation
|
||||
return {
|
||||
@@ -171,14 +159,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
version: VERSION,
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
: item,
|
||||
)
|
||||
: output.slice(),
|
||||
output: event.response?.output ? [...event.response.output] : output.slice(),
|
||||
} satisfies CheckpointValue,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -923,7 +923,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
|
||||
const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
@@ -935,11 +935,7 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
|
||||
// An item without an id adopts the id already open in its output slot,
|
||||
// otherwise it gets a locally minted one.
|
||||
const resolveItem = (
|
||||
state: Pick<ParserState, "outputItems">,
|
||||
item: StreamItem,
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
|
||||
...item,
|
||||
id:
|
||||
item.id ??
|
||||
@@ -949,7 +945,7 @@ const resolveItem = (
|
||||
|
||||
// Registered output slots are authoritative for `item_id` routing, and items
|
||||
// are resolved here so everything downstream can rely on `item.id`.
|
||||
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
|
||||
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
|
||||
...input,
|
||||
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
|
||||
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition, ToolEntry } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -13,7 +13,6 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -115,18 +114,6 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
/** Request control, never conversation content. */
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -204,35 +191,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
})
|
||||
})
|
||||
|
||||
const checkpointBody = {
|
||||
schema: CheckpointBody,
|
||||
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
|
||||
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
|
||||
const overlay = request.http?.body
|
||||
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
|
||||
if (
|
||||
overlay?.input !== undefined ||
|
||||
overlay?.previous_response_id !== undefined ||
|
||||
overlay?.conversation !== undefined
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Trigger compaction requires complete canonical history, not an input or continuation override",
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
|
||||
...mergeJsonRecords(native, overlay),
|
||||
input: [...native.input, { type: "compaction_trigger" }],
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: undefined,
|
||||
context_management: undefined,
|
||||
text: undefined,
|
||||
max_output_tokens: undefined,
|
||||
max_tool_calls: undefined,
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
@@ -310,7 +268,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
|
||||
import { Protocol } from "../../route/protocol.js"
|
||||
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
interface State {
|
||||
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
|
||||
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
|
||||
readonly responseID?: string
|
||||
}
|
||||
|
||||
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
|
||||
state: State,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
const event = OpenResponses.normalize(state.parser, input)
|
||||
const item = event.item
|
||||
if (!item) return state
|
||||
const parser =
|
||||
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
|
||||
? state.parser
|
||||
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
|
||||
const next = parser === state.parser ? state : { ...state, parser }
|
||||
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
|
||||
if (
|
||||
event.output_index !== undefined &&
|
||||
Object.entries(state.parser.outputItems).some(
|
||||
([index, id]) => id === item.id && Number(index) !== event.output_index,
|
||||
)
|
||||
)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
|
||||
if (!item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
|
||||
const previous = state.checkpoints[item.id]
|
||||
if (previous && previous.encrypted !== item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
|
||||
if (previous) return next
|
||||
return {
|
||||
...next,
|
||||
checkpoints: {
|
||||
...state.checkpoints,
|
||||
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
|
||||
},
|
||||
} satisfies State
|
||||
})
|
||||
|
||||
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
|
||||
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
|
||||
const source = request.model.route
|
||||
let result: CompactionCheckpointResponse | undefined
|
||||
// Route registries erase the frame type. The codec validates that boundary before parsing.
|
||||
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
|
||||
const protocol = Protocol.make({
|
||||
id: source.protocol,
|
||||
body,
|
||||
stream: {
|
||||
event,
|
||||
initial: (request: LLMRequest): State => ({
|
||||
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
|
||||
checkpoints: {},
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
|
||||
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
|
||||
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
|
||||
if (event.type === "error" || event.type === "response.failed")
|
||||
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
|
||||
if (event.type === "response.incomplete")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
|
||||
return [yield* onOutputItem(state, event), []] as const
|
||||
if (event.type !== "response.completed") return [state, []] as const
|
||||
const responseID = event.response?.id
|
||||
if (!responseID?.trim())
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
|
||||
if (event.response?.status !== undefined && event.response.status !== "completed")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
|
||||
let next = state
|
||||
for (const [index, item] of (event.response?.output ?? []).entries()) {
|
||||
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
|
||||
}
|
||||
const checkpoints = Object.values(next.checkpoints)
|
||||
const checkpoint = checkpoints[0]
|
||||
if (checkpoints.length !== 1 || !checkpoint)
|
||||
return yield* ProviderShared.eventError(
|
||||
source.id,
|
||||
"Compaction response must contain exactly one checkpoint",
|
||||
)
|
||||
result = new CompactionCheckpointResponse({
|
||||
checkpoint,
|
||||
responseID,
|
||||
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
|
||||
}),
|
||||
},
|
||||
})
|
||||
const route = Route.make({
|
||||
id: source.id,
|
||||
provider: source.provider,
|
||||
providerMetadataKey: source.providerMetadataKey,
|
||||
protocol,
|
||||
endpoint: source.endpoint,
|
||||
auth: source.auth,
|
||||
transport: source.transport,
|
||||
})
|
||||
const native = yield* body.from(request)
|
||||
// The body builder already applied and validated overlays. Do not let transport reapply them.
|
||||
const preparedRequest = LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
})
|
||||
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
|
||||
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
|
||||
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
|
||||
return result
|
||||
})
|
||||
|
||||
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -39,7 +39,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
@@ -103,7 +102,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
@@ -169,7 +168,7 @@ const config = (settings: Settings): Config => {
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
CompactOperation
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
@@ -132,11 +132,10 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof OpenAIResponses.route.compact
|
||||
>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
compact: XAIResponses.compact,
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,11 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
+38
-113
@@ -14,7 +14,6 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -39,7 +38,7 @@ export interface RouteBody<Body> {
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
readonly id: string
|
||||
@@ -54,15 +53,7 @@ export interface Route<
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
): Route<Body, Prepared, Next>
|
||||
(
|
||||
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
|
||||
): Route<Body, Prepared, Compact>
|
||||
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
|
||||
}
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
@@ -83,7 +74,7 @@ export interface Route<
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
@@ -110,7 +101,6 @@ export interface RouteDefaultsInput {
|
||||
}
|
||||
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
@@ -121,7 +111,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
@@ -172,7 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: CompactMethod
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -196,64 +189,12 @@ export type CompactOperation = (
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type TriggerCompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options: TriggerCompactOptions,
|
||||
) => Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
|
||||
/** Protocol capabilities, not deployment/model eligibility. */
|
||||
export interface CompactionOperations {
|
||||
readonly endpoint?: CompactOperation
|
||||
readonly trigger?: TriggerCompactOperation
|
||||
}
|
||||
|
||||
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
|
||||
readonly mechanism?: "endpoint"
|
||||
readonly webSocket?: never
|
||||
}
|
||||
|
||||
export interface TriggerCompactOptions extends StreamOptions {
|
||||
readonly mechanism: "trigger"
|
||||
}
|
||||
|
||||
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
|
||||
}
|
||||
export type CheckpointRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
}
|
||||
|
||||
export interface CompactMethod<R = never> {
|
||||
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
|
||||
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
|
||||
}
|
||||
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options?: { readonly mechanism?: "endpoint" },
|
||||
): request is CompactionRequest
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options: { readonly mechanism: "trigger" },
|
||||
): request is CheckpointRequest
|
||||
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
|
||||
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
|
||||
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
|
||||
return request.model.route.compact?.endpoint !== undefined
|
||||
}
|
||||
|
||||
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
|
||||
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
|
||||
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
|
||||
})
|
||||
}
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
@@ -275,7 +216,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -297,7 +238,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -385,10 +326,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
compact: "compact" in patch ? compact : routeInput.compact,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
@@ -403,7 +343,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -500,12 +440,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, Prepared, Compact>
|
||||
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -621,23 +561,14 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
|
||||
export function compact(
|
||||
export const compact = (
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service>
|
||||
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.gen(function* () {
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return yield* client.compact(request, options)
|
||||
return yield* unsupportedCompaction(request, options?.mechanism)
|
||||
return yield* client.compact(request, options)
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
@@ -651,27 +582,21 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options))
|
||||
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
|
||||
return unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact,
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
})
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,12 +9,6 @@ export type {
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
CompactionRequest,
|
||||
CheckpointRequest,
|
||||
EndpointCompactOptions,
|
||||
TriggerCompactOptions,
|
||||
} from "./client.js"
|
||||
export * from "./executor.js"
|
||||
export { Auth } from "./auth.js"
|
||||
|
||||
@@ -97,21 +97,6 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
|
||||
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
|
||||
"LLM.CompactionCheckpointResponse",
|
||||
)({
|
||||
checkpoint: CompactionPart.pipe(
|
||||
Schema.refine(
|
||||
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
|
||||
part.encrypted !== undefined && part.encrypted.length > 0,
|
||||
{ message: "A checkpoint response requires encrypted compaction content" },
|
||||
),
|
||||
),
|
||||
responseID: Schema.String.check(Schema.isPattern(/\S/)),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactionOperations } from "../route/client.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
@@ -194,7 +194,7 @@ export class LanguageModel<
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
id: ModelID.make(input.id),
|
||||
@@ -206,7 +206,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
return {
|
||||
@@ -218,11 +218,11 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
@@ -241,7 +241,7 @@ export class LanguageModel<
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
@@ -249,7 +249,7 @@ export namespace LanguageModel {
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
|
||||
+11
-39
@@ -1,17 +1,10 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import {
|
||||
LLMClient,
|
||||
type CompactionRequest,
|
||||
type CheckpointRequest,
|
||||
type EndpointCompactOptions,
|
||||
type TriggerCompactOptions,
|
||||
} from "./route/client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -20,11 +13,7 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response =
|
||||
| readonly LLMEvent[]
|
||||
| Stream.Stream<LLMEvent, AIError>
|
||||
| CompactionResponse
|
||||
| CompactionCheckpointResponse
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -143,38 +132,21 @@ const make = (options: LayerOptions) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
|
||||
if (response instanceof CompactionResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(
|
||||
request: LLMRequest,
|
||||
options?: EndpointCompactOptions | TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
|
||||
return take(request).pipe(
|
||||
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
|
||||
if (options?.mechanism === "trigger")
|
||||
return response instanceof CompactionCheckpointResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
|
||||
return response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse")
|
||||
}),
|
||||
)
|
||||
}
|
||||
const test = Test.of({
|
||||
compact,
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionCheckpointResponse,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
OpenAI,
|
||||
OpenAICompatibleResponses,
|
||||
XAI,
|
||||
} from "../../src/providers.js"
|
||||
import type { WebSocketChannelExecutor } from "../../src/route.js"
|
||||
import type { RoutePatch } from "../../src/route/client.js"
|
||||
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
|
||||
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
|
||||
|
||||
declare const webSocket: WebSocketChannelExecutor
|
||||
const model = OpenAI.configure().responses("fixture")
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.map((result) => {
|
||||
result satisfies CompactionCheckpointResponse
|
||||
result.checkpoint.encrypted satisfies string
|
||||
result.responseID satisfies string
|
||||
// @ts-expect-error A trigger does not return replacement history.
|
||||
result.replacement
|
||||
}),
|
||||
)
|
||||
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
|
||||
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
|
||||
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
|
||||
LLMClient.compact(request, { webSocket })
|
||||
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
|
||||
LLMClient.compact(request, { mechanism: "other" })
|
||||
|
||||
for (const selected of [
|
||||
model,
|
||||
OpenAI.model("fixture", {}),
|
||||
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.make(LanguageModel.input(model)),
|
||||
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
|
||||
}
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
|
||||
|
||||
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
|
||||
const xai = XAI.configure().responses("fixture")
|
||||
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
|
||||
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
|
||||
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error xAI endpoint support does not imply trigger support.
|
||||
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
|
||||
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
|
||||
|
||||
const unsupported = {
|
||||
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
|
||||
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
|
||||
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Bedrock does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
|
||||
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
|
||||
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
|
||||
// @ts-expect-error Anthropic does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
|
||||
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Azure Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
|
||||
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the model replaces its capability.
|
||||
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the route replaces its capability.
|
||||
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
|
||||
}),
|
||||
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
|
||||
{ mechanism: "trigger" },
|
||||
)
|
||||
|
||||
declare const dynamic: LLMRequest
|
||||
declare const patch: Partial<LLMRequest.Input>
|
||||
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
|
||||
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Explicitly removing operations removes trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Dynamic models must be narrowed.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
|
||||
}
|
||||
if (LLMClient.canCompact(dynamic)) {
|
||||
LLMClient.compact(dynamic)
|
||||
// @ts-expect-error Endpoint narrowing does not grant trigger support.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
}
|
||||
// @ts-expect-error A dynamic model override cannot preserve trigger support.
|
||||
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
|
||||
@@ -1,243 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest } from "../../src/index.js"
|
||||
import { Azure, OpenAI } from "../../src/providers.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
|
||||
const trigger = { mechanism: "trigger" } as const
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toBe("/v1/responses")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
expect(request.headers.authorization).toBe("Bearer fixture")
|
||||
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
|
||||
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
|
||||
const body = JSON.parse(text)
|
||||
expect(body).toMatchObject({
|
||||
model: "fixture",
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: "Keep the context",
|
||||
parallel_tool_calls: true,
|
||||
prompt_cache_key: "session-key",
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
|
||||
})
|
||||
expect(body.tools).toHaveLength(1)
|
||||
expect(body.tools[0].name).toBe("lookup")
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
expect(body.context_management).toBeUndefined()
|
||||
expect(body.text).toBeUndefined()
|
||||
expect(body.max_output_tokens).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
return respond(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [checkpoint],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
input_tokens_details: { cached_tokens: 40 },
|
||||
output_tokens: 5,
|
||||
total_tokens: 105,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const input = LLM.request({
|
||||
model: request.model,
|
||||
system: "Keep the context",
|
||||
prompt: "hello",
|
||||
promptCacheKey: "session-key",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: { type: "tool", name: "lookup" },
|
||||
generation: { maxTokens: 1 },
|
||||
providerOptions: {
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
contextManagement: [{ type: "compaction" }],
|
||||
},
|
||||
http: {
|
||||
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
service_tier: "priority",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
store: true,
|
||||
stream: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
tool_choice: "required",
|
||||
},
|
||||
},
|
||||
})
|
||||
const original = LLMRequest.input(input)
|
||||
const result = yield* LLMClient.compact(input, {
|
||||
...trigger,
|
||||
http: (request, next) => {
|
||||
calls.push("http")
|
||||
return next(request)
|
||||
},
|
||||
})
|
||||
expect(result.checkpoint).toMatchObject({
|
||||
type: "compaction",
|
||||
provider: "openai",
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
expect(result.responseID).toBe("resp_1")
|
||||
expect(result.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 5,
|
||||
totalTokens: 105,
|
||||
cacheReadInputTokens: 40,
|
||||
})
|
||||
expect(LLMRequest.input(input)).toEqual(original)
|
||||
expect(calls).toEqual(["http"])
|
||||
}),
|
||||
)
|
||||
|
||||
const idless = { type: "compaction", encrypted_content: "opaque" }
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
|
||||
},
|
||||
{ type: "response.output_text.delta", delta: "do not expose this" },
|
||||
{ type: "response.output_item.added", output_index: 1, item: { type: "compaction" } },
|
||||
{ type: "response.output_item.done", output_index: 1, item: idless },
|
||||
{ type: "response.output_item.done", output_index: 1, item: idless },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, idless],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
).effect("correlates an ID-less checkpoint across repeated events and ignores other output", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.checkpoint.id).toBeString()
|
||||
expect("message" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, events] of Object.entries({
|
||||
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
|
||||
multiple: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
|
||||
],
|
||||
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
|
||||
malformed: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
|
||||
],
|
||||
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
|
||||
incomplete: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
|
||||
],
|
||||
failed: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
|
||||
],
|
||||
})) {
|
||||
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
|
||||
testEffect(
|
||||
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
|
||||
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
|
||||
"rejects clean EOF without response.completed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
"endpoint support does not grant trigger support, including for untyped callers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* LLMClient.Service
|
||||
const unsupported = LLM.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") })
|
||||
expect(LLMClient.canCompact(unsupported)).toBe(true)
|
||||
expect(LLMClient.canCompact(unsupported, trigger)).toBe(false)
|
||||
// @ts-expect-error Exercise untyped consumers; the service must reject before sending.
|
||||
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
|
||||
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
|
||||
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(fixedResponse("must not use HTTP")).effect(
|
||||
"invalid checkpoint output is rejected before acknowledging channel completion",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
let completed = 0
|
||||
const operation = LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket: {
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: Stream.make(
|
||||
JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [] },
|
||||
}),
|
||||
),
|
||||
complete: Effect.sync(() => {
|
||||
completed++
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const error = yield* operation.pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(completed).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive and provider notifications before response.created", () =>
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
@@ -534,7 +534,6 @@ describe("OpenAI Responses route", () => {
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
@@ -658,7 +657,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
it.effect("continues a promoted steer after the completed assistant output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
@@ -672,7 +671,7 @@ describe("OpenAI Responses route", () => {
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -700,37 +699,42 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
it.effect("continues store-false reasoning while retaining the output item ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
|
||||
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
}
|
||||
const first = continuationDriver(request)
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: reasoning,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }] },
|
||||
}),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const next = continuationDriver({
|
||||
...request,
|
||||
input: [...firstInput, reasoning, { role: "user", content: [{ type: "input_text", text: "Continue" }] }],
|
||||
input: [
|
||||
...firstInput,
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
@@ -740,11 +744,6 @@ describe("OpenAI Responses route", () => {
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
|
||||
})
|
||||
const edited = yield* continuationDriver({
|
||||
...request,
|
||||
input: [...firstInput, { ...reasoning, encrypted_content: "edited" }, { role: "user", content: "Continue" }],
|
||||
}).create(saved)
|
||||
expect(edited.mode).toBe("full")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -80,31 +79,6 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("returns scripted checkpoints only for trigger compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
|
||||
const checkpoint = new CompactionCheckpointResponse({
|
||||
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
|
||||
responseID: "resp_fixture",
|
||||
})
|
||||
const endpoint = new CompactionResponse({ replacement: [] })
|
||||
yield* client.push(checkpoint, endpoint, checkpoint, checkpoint)
|
||||
const operation = LLMClient.compact(request, { mechanism: "trigger" })
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
expect(yield* operation).toBe(checkpoint)
|
||||
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
|
||||
)
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
}
|
||||
|
||||
@@ -88,88 +88,27 @@ story("renders a draft once and supports editing, caret restoration, and failure
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
})
|
||||
|
||||
story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => {
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
await component.getByRole("status").click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("balanced")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await component.getByRole("status").click()
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await expect(control).toHaveCSS("pointer-events", "none")
|
||||
|
||||
await input.hover()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await expect(control).toHaveCSS("pointer-events", "auto")
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(input).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
await input.hover()
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await expect(control).toBeVisible()
|
||||
await expect(high).toBeVisible()
|
||||
await high.click()
|
||||
await component.getByRole("status").click()
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await component.getByRole("status").click()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
})
|
||||
|
||||
story("keeps default thinking accessible by keyboard without composer hover", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const control = component.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
// Tab through Add, Agent, and Model to the visually hidden thinking trigger.
|
||||
for (let count = 0; count < 4; count++) await page.keyboard.press("Tab")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(control).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("End")
|
||||
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).not.toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await input.focus()
|
||||
await expect(control).toBeVisible()
|
||||
await input.blur()
|
||||
await expect(control).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/slash-skills"
|
||||
const projectID = "proj_slash_skills"
|
||||
const sessionID = "ses_slash_skills"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
async function setup(page: Page, queued = false) {
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "slash-skills",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title: "Slash skills",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
|
||||
inbox: queued
|
||||
? [
|
||||
{
|
||||
id: "inb_slash_skill",
|
||||
sessionID,
|
||||
timeCreated: 1700000000000,
|
||||
type: "user",
|
||||
payload: { text: "Explain caching" },
|
||||
delivery: "queue",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
findFiles: ({ query }) =>
|
||||
query.includes("cache")
|
||||
? [
|
||||
{
|
||||
name: "cache.ts",
|
||||
path: "src/cache.ts",
|
||||
absolute: `${directory}/src/cache.ts`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
onPrompt: ({ body }) => prompts.push(body),
|
||||
})
|
||||
await page.route("**/api/skill?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [
|
||||
{ id: "show-me", slash: true, autoinvoke: false },
|
||||
{ id: "hidden", slash: false },
|
||||
{ id: "implicit" },
|
||||
{ id: "review", slash: true },
|
||||
{ id: "model", slash: true },
|
||||
].map((skill) => ({
|
||||
name: skill.id === "show-me" ? "Show Me" : skill.id,
|
||||
description: "Explain the current topic visually",
|
||||
location: `/skills/${skill.id}/SKILL.md`,
|
||||
content: "Explain visually",
|
||||
...skill,
|
||||
})),
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/command?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const editor = composer.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
return { prompts, composer, editor }
|
||||
}
|
||||
|
||||
for (const selection of ["keyboard", "pointer"]) {
|
||||
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page)
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toContainText("/show-me")
|
||||
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
|
||||
if (selection === "keyboard") await editor.press("Enter")
|
||||
if (selection === "pointer") await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.pressSequentially("explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
await expect(editor).toBeEmpty()
|
||||
})
|
||||
}
|
||||
|
||||
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
|
||||
const { editor } = await setup(page)
|
||||
await editor.fill("/")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
|
||||
for (const id of ["hidden", "implicit", "review", "model"]) {
|
||||
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
|
||||
}
|
||||
await editor.press("Escape")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.fill("@hidden")
|
||||
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
|
||||
await expect(hidden).toContainText("@hidden")
|
||||
await hidden.click()
|
||||
await expect(editor).toHaveText("@hidden ")
|
||||
await expect(editor).toBeFocused()
|
||||
})
|
||||
|
||||
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
|
||||
const { prompts, composer, editor } = await setup(page)
|
||||
await editor.fill("explain @cache")
|
||||
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
|
||||
await expect(file).toBeVisible()
|
||||
await file.click()
|
||||
await expect(editor).toHaveText("explain @src/cache.ts ")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
|
||||
})
|
||||
})
|
||||
|
||||
for (const select of [true, false]) {
|
||||
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page, true)
|
||||
const row = page.locator('[data-component="session-queue-row"]')
|
||||
await row.getByText("Explain caching", { exact: true }).click()
|
||||
await expect(editor).toHaveText("Explain caching")
|
||||
if (select) {
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await editor.pressSequentially("explain caching")
|
||||
}
|
||||
if (!select) await editor.fill("/show-me explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
delivery: "queue",
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_dropzone"
|
||||
const directory = "C:/OpenCode/NewSessionDropzone"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_dropzone",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "new-session-dropzone",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
|
||||
const surface = page.locator('[data-component="new-session"]')
|
||||
const dropzone = page.locator('[data-component="session-dropzone"]')
|
||||
const transfer = await page.evaluateHandle(() => {
|
||||
const value = new DataTransfer()
|
||||
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
|
||||
return value
|
||||
})
|
||||
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
|
||||
await expect(dropzone).toHaveAttribute("data-visible", "true")
|
||||
await expect(dropzone).toContainText("Drop files to add")
|
||||
|
||||
await surface.dispatchEvent("drop", { dataTransfer: transfer })
|
||||
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
})
|
||||
@@ -134,7 +134,7 @@ for (const viewport of [
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(pending.title).toHaveText("Session")
|
||||
await expect(pending.title).toHaveText("New session")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
@@ -238,7 +238,7 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
!frame.message ||
|
||||
!frame.spinner ||
|
||||
frame.draft !== followUp ||
|
||||
!["Session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
),
|
||||
).toEqual([])
|
||||
const after = await title.boundingBox()
|
||||
@@ -598,7 +598,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
const title = preparing.getByRole("heading", { level: 1 })
|
||||
await expect(preparing).toBeVisible()
|
||||
await expect(title).toHaveText("Session")
|
||||
await expect(title).toHaveText("New session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
|
||||
const sessionID = "ses_prompt_thinking_level_regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
|
||||
test("shows the thinking level control while relevant", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -52,57 +52,30 @@ test("shows thinking on hover or a non-default selection while preserving keyboa
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await expect(control).toHaveCSS("pointer-events", "none")
|
||||
await idleComposer(page)
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await input.hover()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(input).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
await input.hover()
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await expect(control).toBeVisible()
|
||||
await expect(high).toBeVisible()
|
||||
await high.click()
|
||||
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await idleComposer(page)
|
||||
await input.focus()
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.click()
|
||||
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
// The single-agent fixture has only Add and Model before the thinking trigger.
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(control).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await idleComposer(page)
|
||||
await expect(control).toBeVisible()
|
||||
})
|
||||
|
||||
async function idleComposer(page: Page) {
|
||||
await page.mouse.move(0, 0)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
|
||||
@@ -78,7 +78,6 @@ export type ComposerAttachmentConfig = {
|
||||
onError: (error: unknown) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
onDragCancel?: (callback: () => void) => () => void
|
||||
store?: (file: File) => Promise<{ id: string; url: string }>
|
||||
}
|
||||
|
||||
@@ -91,9 +90,6 @@ export function createComposerAttachments(
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
},
|
||||
) {
|
||||
const clearDrag = () => {
|
||||
input.setDraggingType(null)
|
||||
}
|
||||
const capture = () => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
@@ -182,7 +178,7 @@ export function createComposerAttachments(
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
event.preventDefault()
|
||||
clearDrag()
|
||||
input.setDraggingType(null)
|
||||
const plainText = event.dataTransfer?.getData("text/plain")
|
||||
if (plainText?.startsWith("file:")) {
|
||||
const path = plainText.slice("file:".length)
|
||||
@@ -195,8 +191,6 @@ export function createComposerAttachments(
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const cancel = input.onDragCancel?.(clearDrag)
|
||||
if (cancel) onCleanup(cancel)
|
||||
makeEventListener(document, "dragover", (event) => {
|
||||
if (input.isDialogActive()) return
|
||||
event.preventDefault()
|
||||
@@ -204,10 +198,7 @@ export function createComposerAttachments(
|
||||
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
|
||||
})
|
||||
makeEventListener(document, "dragleave", (event) => {
|
||||
if (!input.isDialogActive() && !event.relatedTarget) clearDrag()
|
||||
})
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
if (event.key === "Escape") clearDrag()
|
||||
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
|
||||
})
|
||||
makeEventListener(document, "drop", handleDrop)
|
||||
})
|
||||
|
||||
@@ -134,7 +134,6 @@ function ComposerStory(props: {
|
||||
},
|
||||
variant: {
|
||||
options: () => [
|
||||
{ id: "default", label: "default" },
|
||||
{ id: "balanced", label: "balanced" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
export function ComposerDropzone(props: {
|
||||
active: boolean
|
||||
input?: { image?: boolean; pdf?: boolean }
|
||||
identity?: () => unknown
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [elements, setElements] = createStore<{ dropzone?: HTMLDivElement }>({})
|
||||
const label = createMemo(() => {
|
||||
if (!props.input?.image && !props.input?.pdf) return language.t("ui.promptInput.dropFiles")
|
||||
if (!props.input.pdf) return language.t("ui.promptInput.dropFiles.image")
|
||||
if (!props.input.image) return language.t("ui.promptInput.dropFiles.pdf")
|
||||
return language.t("ui.promptInput.dropFiles.imagePdf")
|
||||
})
|
||||
const presence = createAnimatedPresence(
|
||||
() => (props.active ? label() : undefined),
|
||||
() => elements.dropzone ?? null,
|
||||
props.identity,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-slot="session-dropzone-blur"
|
||||
data-visible={props.active}
|
||||
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
|
||||
style={{
|
||||
"-webkit-mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
"-webkit-mask-composite": "source-in",
|
||||
"mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
"mask-composite": "intersect",
|
||||
}}
|
||||
/>
|
||||
<Show when={presence.present()}>
|
||||
<div
|
||||
ref={(element) => setElements("dropzone", element)}
|
||||
data-component="session-dropzone"
|
||||
data-visible={props.active}
|
||||
class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none"
|
||||
>
|
||||
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
|
||||
<div
|
||||
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
|
||||
style={{
|
||||
"-webkit-mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="session-dropzone-stripes"
|
||||
class="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
background:
|
||||
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
|
||||
"-webkit-mask-image":
|
||||
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
|
||||
"mask-image":
|
||||
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-dropzone-content"
|
||||
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
|
||||
data-visible={props.active}
|
||||
>
|
||||
<div
|
||||
data-slot="session-dropzone-upload"
|
||||
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
|
||||
</div>
|
||||
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{presence.value()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ComposerPersistedState } from "../types"
|
||||
import { createComposerEditorActions } from "./actions"
|
||||
|
||||
@@ -98,26 +97,4 @@ describe("Composer store", () => {
|
||||
|
||||
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
|
||||
})
|
||||
|
||||
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
|
||||
const prompt = createPromptStore()
|
||||
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
|
||||
prompt.addMention(
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make("show-me"),
|
||||
name: Skill.Name.make("Show Me"),
|
||||
content: "/show-me",
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
{ start: 0, end: 0 },
|
||||
)
|
||||
expect(prompt.state.prompt).toMatchObject([
|
||||
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
|
||||
{ type: "text", content: " ", start: 8, end: 9 },
|
||||
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
|
||||
])
|
||||
expect(prompt.state.cursor).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,15 +69,12 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
|
||||
clearRetry()
|
||||
},
|
||||
addMention(
|
||||
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
|
||||
range?: { start: number; end: number },
|
||||
) {
|
||||
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
|
||||
const text = store()
|
||||
.prompt.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
const end = range?.end ?? store().cursor ?? text.length
|
||||
const start = range?.start ?? text.slice(0, end).lastIndexOf("@")
|
||||
const end = store().cursor ?? text.length
|
||||
const start = text.slice(0, end).lastIndexOf("@")
|
||||
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
|
||||
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
|
||||
clearRetry()
|
||||
@@ -116,9 +113,6 @@ function insertMention(
|
||||
end: number,
|
||||
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
|
||||
): ComposerPrompt {
|
||||
if (start === 0 && end === 0) {
|
||||
return withOffsets([mention, { type: "text", content: " ", start: 0, end: 0 }, ...prompt])
|
||||
}
|
||||
let position = 0
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
|
||||
@@ -118,6 +118,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -128,6 +129,12 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onDragLeave={props.controller.onDragLeave}
|
||||
onDrop={props.controller.onDrop}
|
||||
>
|
||||
<Show when={state.drag === "active"}>
|
||||
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
|
||||
{i18n.t("ui.promptInput.dropFiles")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
@@ -263,7 +270,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
class={control.current() === "default" ? "composer-variant-default" : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
@@ -597,14 +603,12 @@ function ComposerEditorConfiguredSelect(props: {
|
||||
keybind?: string[]
|
||||
control: ComposerSelectControl
|
||||
model?: boolean
|
||||
class?: string
|
||||
}) {
|
||||
const current = () => props.control.current()
|
||||
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
|
||||
return (
|
||||
<ComposerEditorSelect
|
||||
title={props.title}
|
||||
class={props.class}
|
||||
keybind={props.control.keybind?.() ?? props.keybind}
|
||||
options={props.control.options()}
|
||||
current={current()}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function createComposerEditor(input: {
|
||||
return
|
||||
}
|
||||
if (command.type === "mention.add") {
|
||||
if (command.item.mention) draft.addMention(command.item.mention, command.range)
|
||||
if (command.item.mention) draft.addMention(command.item.mention)
|
||||
return
|
||||
}
|
||||
if (command.type === "popover.filter") {
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit, withSlashSkill } from "./submit"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -42,14 +42,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
|
||||
const interaction = createComposerEditorState(prompt.mode.current())
|
||||
createEffect(
|
||||
on(
|
||||
() => (adapter.ready() ? prompt.mode.current() : undefined),
|
||||
(mode) => {
|
||||
if (!mode) return
|
||||
// Project external draft changes without another mode write clearing restored retry metadata.
|
||||
interaction[1](mode === "shell" ? { mode, popover: { type: "closed" } } : { mode })
|
||||
},
|
||||
),
|
||||
on(adapter.ready, (ready) => {
|
||||
if (ready) interaction[1]("mode", prompt.mode.current())
|
||||
}),
|
||||
)
|
||||
const mode = () => interaction[0].mode
|
||||
const history = createComposerHistory()
|
||||
@@ -241,42 +236,22 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const slashSkills = createMemo(() =>
|
||||
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
|
||||
)
|
||||
const commands = createMemo<ComposerSuggestion[]>(() => [
|
||||
...slashCommands().map((item) => ({
|
||||
const commands = createMemo<ComposerSuggestion[]>(() =>
|
||||
slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
kind: "command" as const,
|
||||
kind: "command",
|
||||
label: `/${item.trigger}`,
|
||||
trigger: item.trigger,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
...slashSkills().map((skill) => ({
|
||||
id: `skill:${skill.id}`,
|
||||
kind: "skill" as const,
|
||||
label: `/${skill.id}`,
|
||||
trigger: skill.id,
|
||||
title: skill.name,
|
||||
description: skill.description,
|
||||
mention: {
|
||||
type: "skill" as const,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content: `/${skill.id}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
})),
|
||||
])
|
||||
)
|
||||
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
|
||||
const submission = createComposerSubmit({
|
||||
adapter,
|
||||
mode,
|
||||
commands: () => data.location.command.list({ directory: sdk().directory }),
|
||||
skills: slashSkills,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
@@ -371,7 +346,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
onDragCancel: platform.onDragCancel,
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
@@ -403,7 +377,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
if (queue?.editing()) {
|
||||
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
|
||||
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
@@ -32,16 +31,6 @@ const selection = {
|
||||
},
|
||||
} satisfies ModelSelection
|
||||
|
||||
const slashSkill = Skill.Info.make({
|
||||
id: Skill.ID.make("show-me"),
|
||||
name: Skill.Name.make("Show Me"),
|
||||
description: "Explain visually",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
|
||||
content: "Explain visually",
|
||||
})
|
||||
|
||||
function controls(): ComposerControls {
|
||||
return {
|
||||
agents: {
|
||||
@@ -64,13 +53,11 @@ function submitInput(
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
skills: () => readonly Skill.Info[] | undefined = () => [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
skills,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
@@ -122,107 +109,6 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("submits a slash skill with its trailing text and attachments", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
|
||||
state.set([
|
||||
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
|
||||
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
|
||||
])
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [slashSkill],
|
||||
).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
expect(request.text).toBe("/show-me explain @src/cache.ts")
|
||||
expect(request.skills).toEqual([
|
||||
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
|
||||
])
|
||||
expect(request.files).toEqual([
|
||||
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
|
||||
])
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ text: "/show-me", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
|
||||
{ text: "/show-me", slash: false, expected: [] },
|
||||
{ text: "/show-me", slash: undefined, expected: [] },
|
||||
{ text: "/show-me-extra", slash: true, expected: [] },
|
||||
{ text: "Explain /show-me", slash: true, expected: [] },
|
||||
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
|
||||
const state = createMemoryComposerState({ prompt: text }).capture()
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: ActiveComposerAdapter = {
|
||||
kind: "active-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
session: () => target,
|
||||
interrupt: async () => undefined,
|
||||
submitted() {},
|
||||
setEditor() {},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [{ ...slashSkill, slash }],
|
||||
).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
expect(request.text).toBe(text)
|
||||
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
|
||||
})
|
||||
|
||||
test("captures slash skills before creating a session in a new worktree", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
|
||||
let skills: readonly Skill.Info[] | undefined = [slashSkill]
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
skills = undefined
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
},
|
||||
}
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => [],
|
||||
() => skills,
|
||||
).submit(new Event("submit"))
|
||||
expect((await admitted.promise).skills).toEqual([
|
||||
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
|
||||
])
|
||||
})
|
||||
|
||||
test("sends one captured value with explicit delivery after selection switches", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const calls: string[] = []
|
||||
@@ -471,8 +357,8 @@ describe("Composer submission", () => {
|
||||
expect(promoted.mode.current()).toBe("shell")
|
||||
})
|
||||
|
||||
test.each(["retry me", "/show-me retry me"])("restores and retries an unacknowledged admission: %s", async (text) => {
|
||||
const state = createMemoryComposerState({ prompt: text }).capture()
|
||||
test("reuses the message ID when an unacknowledged admission is retried", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
|
||||
const attempts: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
@@ -482,7 +368,6 @@ describe("Composer submission", () => {
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
expect(value.skills?.map((skill) => skill.id)).toEqual(text.startsWith("/") ? ["show-me"] : [])
|
||||
throw new Error("network unavailable")
|
||||
},
|
||||
})
|
||||
@@ -501,13 +386,7 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
() => [slashSkill],
|
||||
)
|
||||
const submission = submitInput(adapter, notify)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -517,7 +396,7 @@ describe("Composer submission", () => {
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: text }])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
@@ -593,13 +472,7 @@ describe("Composer submission", () => {
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(
|
||||
adapter,
|
||||
undefined,
|
||||
"normal",
|
||||
() => catalog,
|
||||
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
|
||||
).submit(new Event("submit"))
|
||||
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
|
||||
|
||||
expect(await sent.promise).toBe("command")
|
||||
expect(requests).toEqual([
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionMessageUser, SkillInfo } from "@opencode-ai/client/promise"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
@@ -29,7 +28,6 @@ type ComposerSubmitInput = {
|
||||
adapter: ComposerAdapter
|
||||
mode: Accessor<"normal" | "shell">
|
||||
commands: Accessor<readonly { name: string }[] | undefined>
|
||||
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
@@ -70,7 +68,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
|
||||
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
|
||||
|
||||
try {
|
||||
const started =
|
||||
@@ -290,27 +287,6 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
|
||||
return { command, arguments: arguments_.join(" ") }
|
||||
}
|
||||
|
||||
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
|
||||
const first = prompt[0]
|
||||
if (first?.type !== "text") return prompt
|
||||
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
|
||||
const skill = skills?.find((item) => item.slash === true && item.id === name)
|
||||
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
|
||||
const content = `/${skill.id}`
|
||||
return [
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
content,
|
||||
start: 0,
|
||||
end: content.length,
|
||||
},
|
||||
{ ...first, content: first.content.slice(content.length), start: content.length },
|
||||
...prompt.slice(1),
|
||||
]
|
||||
}
|
||||
|
||||
async function sendCommand(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
|
||||
@@ -35,7 +35,7 @@ export type ComposerInteractionEvent =
|
||||
export type ComposerInteractionCommand =
|
||||
| { type: "draft.setText"; value: string }
|
||||
| { type: "draft.addText"; value: string }
|
||||
| { type: "mention.add"; item: ComposerSuggestion; range?: { start: number; end: number } }
|
||||
| { type: "mention.add"; item: ComposerSuggestion }
|
||||
| { type: "popover.filter"; popover: "command" | "context"; query: string }
|
||||
| { type: "suggestion.select"; id: string }
|
||||
| { type: "focus.editor" }
|
||||
@@ -178,13 +178,7 @@ function suggestionSelected(
|
||||
: replaceTrigger(current, "/", `${item.label} `),
|
||||
})
|
||||
} else {
|
||||
commands.push({
|
||||
type: "mention.add",
|
||||
item,
|
||||
...(item.kind === "skill" && item.label.startsWith("/")
|
||||
? { range: { start: 0, end: state.popover.type === "command-menu" ? 0 : current.length } }
|
||||
: {}),
|
||||
})
|
||||
commands.push({ type: "mention.add", item })
|
||||
}
|
||||
commands.push({ type: "focus.editor" })
|
||||
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
|
||||
|
||||
@@ -319,69 +319,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-content-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-upload-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-stripes-drift {
|
||||
to {
|
||||
background-position: 33.941px 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] {
|
||||
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
|
||||
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
|
||||
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
|
||||
animation: session-dropzone-stripes-drift 3s linear infinite;
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"] {
|
||||
--session-dropzone-wash: 4%;
|
||||
--session-dropzone-stripe: 7%;
|
||||
--session-dropzone-card: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="session-dropzone"] {
|
||||
--session-dropzone-wash: 6%;
|
||||
--session-dropzone-stripe: 14%;
|
||||
--session-dropzone-card: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-dropzone"][data-visible="true"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"] {
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
|
||||
/** Keep the prompt width and side padding in sync with SessionComposerRegion. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full px-3 md:max-w-[1000px] md:mx-auto"
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
|
||||
import {
|
||||
@@ -55,13 +54,9 @@ export function NewSessionView(props: {
|
||||
data-component="new-session"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<ComposerDropzone
|
||||
active={props.composer.state.drag === "active"}
|
||||
input={props.composer.model.selection.current()?.capabilities.input}
|
||||
/>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<Wordmark class="mx-auto h-auto w-full max-w-[720px] text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
|
||||
@@ -59,9 +59,6 @@ type PlatformBase = {
|
||||
/** Resolve the native source path for a desktop File. */
|
||||
getPathForFile?(file: File): string
|
||||
|
||||
/** Observe native drag cancellation that does not reach the renderer event loop. */
|
||||
onDragCancel?(callback: () => void): () => void
|
||||
|
||||
/** Open a native save file dialog and write content to the selected path (desktop only) */
|
||||
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@ import { useComposerState } from "@/composer/persistence"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { SessionModel } from "../model"
|
||||
|
||||
export function createActiveComposerAdapter(input: {
|
||||
sessionID: string
|
||||
session: SessionModel
|
||||
controls: Accessor<ComposerControls>
|
||||
submitted: () => void
|
||||
setEditor: (element: HTMLDivElement) => void
|
||||
}) {
|
||||
const id = input.sessionID
|
||||
const id = input.session.identity.params.id
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
|
||||
const prompt = useComposerState()
|
||||
prompt.current()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import type { ComposerControls } from "@/composer/adapter"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { createActiveComposerAdapter } from "./adapter"
|
||||
import { createSessionQueue } from "./queue"
|
||||
import { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
export function createSessionComposerController(input: {
|
||||
sessionID: string
|
||||
controls: Accessor<ComposerControls>
|
||||
dock: Parameters<typeof createSessionComposerRegionController>[0]
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const region = createSessionComposerRegionController(input.dock)
|
||||
let editor: HTMLDivElement | undefined
|
||||
const adapter = createActiveComposerAdapter({
|
||||
sessionID: input.sessionID,
|
||||
controls: input.controls,
|
||||
submitted: region.onResponseSubmit,
|
||||
setEditor: (element) => {
|
||||
editor = element
|
||||
region.setPromptRef(element)
|
||||
},
|
||||
})
|
||||
const queue = createSessionQueue({
|
||||
sessionID: input.sessionID,
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
restoreFocus: (cursor) => {
|
||||
const target = editor
|
||||
if (!target) return
|
||||
requestAnimationFrame(() => {
|
||||
target.focus()
|
||||
setCursorPosition(target, cursor)
|
||||
})
|
||||
},
|
||||
})
|
||||
const composer = createComposerModel(adapter, { queue })
|
||||
const editable = createMemo(() => region.showComposer() && !region.child())
|
||||
// Requests hide the view without disposing its draft or queue edit.
|
||||
createEffect(on(editable, () => composer.onDragLeave()))
|
||||
|
||||
return {
|
||||
region,
|
||||
queue,
|
||||
composer,
|
||||
drop: {
|
||||
active: () => editable() && composer.state.drag === "active",
|
||||
input: () => composer.model.selection.current()?.capabilities.input,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
|
||||
@@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
|
||||
@@ -29,7 +30,7 @@ export function createSessionQueue(input: {
|
||||
draft: ComposerStateTarget
|
||||
working: Accessor<boolean>
|
||||
behavior: Accessor<ComposerDelivery>
|
||||
restoreFocus: (cursor: number) => void
|
||||
composer: Accessor<ComposerModel | undefined>
|
||||
}) {
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
@@ -158,9 +159,9 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
})
|
||||
const text = queuedPromptText(item)
|
||||
input.draft.mode.set("normal")
|
||||
input.composer()?.dispatch({ type: "mode.normal" })
|
||||
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
input.restoreFocus(text.length)
|
||||
input.composer()?.restoreFocus(text.length)
|
||||
return true
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
@@ -169,10 +170,10 @@ export function createSessionQueue(input: {
|
||||
setState("editing", undefined)
|
||||
// Mode first, then prompt, then retry: mode and prompt writes both clear
|
||||
// the retry marker.
|
||||
input.draft.mode.set(editing.stash.mode)
|
||||
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
|
||||
input.draft.set(editing.stash.prompt, editing.stash.cursor)
|
||||
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
|
||||
input.restoreFocus(editing.stash.cursor)
|
||||
input.composer()?.restoreFocus(editing.stash.cursor)
|
||||
}
|
||||
const confirmEdit = (delivery: ComposerDelivery) => {
|
||||
const editing = state.editing
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
@@ -24,16 +25,18 @@ import { restorePromptModel, syncPromptModel, syncSessionModel } from "../sessio
|
||||
import type { SessionTimelineInteraction } from "../timeline/interaction"
|
||||
import { createSessionRevert } from "../revert"
|
||||
import { SessionComposerRegion } from "./session-composer-region"
|
||||
import { createSessionComposerController, type SessionComposerController } from "./controller"
|
||||
import { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||
import { createActiveComposerAdapter } from "./adapter"
|
||||
import { createSessionQueue } from "./queue"
|
||||
import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
screen: SessionScreenLayout
|
||||
timeline: SessionTimelineInteraction
|
||||
visible: Accessor<boolean>
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
@@ -177,30 +180,7 @@ export function createActiveSessionRegion(input: {
|
||||
},
|
||||
])
|
||||
|
||||
const dock = {
|
||||
state,
|
||||
parentID: input.session.data.parentID,
|
||||
centered: input.screen.centered,
|
||||
onResponseSubmit: input.timeline.actions.resume,
|
||||
openParent,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
promptRef = element
|
||||
},
|
||||
setDockRef: input.timeline.view.setDockRef,
|
||||
}
|
||||
const active = createMemo(
|
||||
on(
|
||||
() => (input.visible() ? input.session.identity.sessionID() : undefined),
|
||||
(sessionID) => (sessionID ? createSessionComposerController({ sessionID, controls, dock }) : undefined),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
active,
|
||||
drop: {
|
||||
active: () => active()?.drop.active() ?? false,
|
||||
input: () => active()?.drop.input(),
|
||||
},
|
||||
actions: {
|
||||
timeline: {
|
||||
get revert() {
|
||||
@@ -210,25 +190,76 @@ export function createActiveSessionRegion(input: {
|
||||
openAttachment,
|
||||
} satisfies SessionUserActions,
|
||||
},
|
||||
requests: state,
|
||||
region: {
|
||||
centered: input.screen.centered,
|
||||
openParent,
|
||||
prompt,
|
||||
setDockRef: input.timeline.view.setDockRef,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
promptRef = element
|
||||
},
|
||||
state,
|
||||
},
|
||||
input: {
|
||||
controls,
|
||||
setPromptRef: (element: HTMLDivElement) => {
|
||||
promptRef = element
|
||||
},
|
||||
},
|
||||
submitted: () => input.timeline.actions.resume(),
|
||||
workspaceMoveEligible: () => true,
|
||||
}
|
||||
}
|
||||
|
||||
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
|
||||
|
||||
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
parentID: props.session.data.parentID,
|
||||
centered: props.model.region.centered,
|
||||
onResponseSubmit: props.onResponseSubmit,
|
||||
openParent: props.model.region.openParent,
|
||||
setPromptRef: props.model.region.setPromptRef,
|
||||
setDockRef: props.model.region.setDockRef,
|
||||
})
|
||||
const adapter = createActiveComposerAdapter({
|
||||
session: props.session,
|
||||
controls: props.model.input.controls,
|
||||
submitted: props.model.submitted,
|
||||
setEditor: props.model.input.setPromptRef,
|
||||
})
|
||||
let composer: ComposerModel | undefined
|
||||
const queue = createSessionQueue({
|
||||
sessionID: requireSessionID(props.session),
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
composer: () => composer,
|
||||
})
|
||||
composer = createComposerModel(adapter, { queue })
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={props.model.region}
|
||||
controller={region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={props.model.composer} borderUnderlay />
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function requireSessionID(session: SessionModel) {
|
||||
const id = session.identity.params.id
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
@@ -135,8 +134,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
session,
|
||||
screen,
|
||||
timeline,
|
||||
visible: conversationVisible,
|
||||
})
|
||||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
@@ -177,7 +176,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
review.mobile.setTab("changes")
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
backgroundTasks={composer.requests.background.tasks()}
|
||||
backgroundTasks={composer.region.state.background.tasks()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -199,11 +198,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<ComposerDropzone
|
||||
active={composer.drop.active()}
|
||||
input={composer.drop.input()}
|
||||
identity={session.layout.tabKey}
|
||||
/>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
@@ -241,7 +235,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<MessageTimeline
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.requests.background}
|
||||
background={composer.region.state.background}
|
||||
actions={composer.actions.timeline}
|
||||
scroll={timeline.scroll}
|
||||
onResumeScroll={timeline.actions.resume}
|
||||
@@ -269,8 +263,10 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => <ActiveSessionComposerRegion model={model} />}
|
||||
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": props.raised,
|
||||
}}
|
||||
|
||||
@@ -242,7 +242,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
)
|
||||
const title = createMemo(() =>
|
||||
pending()
|
||||
? language.t("session.tab.session")
|
||||
? language.t("command.session.new")
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
|
||||
@@ -647,8 +647,7 @@
|
||||
|
||||
.settings-tab-search {
|
||||
position: relative;
|
||||
width: calc(100% - 2px);
|
||||
margin-inline: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-tab-search [data-component="text-input-v2"] {
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SettingsProjects: Component = () => {
|
||||
const name = () => displayName(props.project)
|
||||
return (
|
||||
<div
|
||||
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
|
||||
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
|
||||
onClick={() => openProjectSettings(props.project, props.server)}
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function Layout(props: ParentProps) {
|
||||
<aside
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
data-slot="vertical-tabs-sidebar"
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { render } from "solid-js/web"
|
||||
import { createComposerAttachments } from "@/composer/attachments/attachments"
|
||||
|
||||
test("clears drag state on platform cancellation", async () => {
|
||||
let cancel = () => {}
|
||||
let subscribed = false
|
||||
let dragging: "image" | "@mention" | null = "image"
|
||||
const dispose = render(() => {
|
||||
createComposerAttachments({
|
||||
capture: () => ({ current: () => [], cursor: () => 0, set: () => {} }),
|
||||
editor: () => undefined,
|
||||
focusEditor: () => {},
|
||||
addPart: () => false,
|
||||
setDraggingType: (type) => (dragging = type),
|
||||
directory: () => "",
|
||||
isDialogActive: () => false,
|
||||
warn: () => {},
|
||||
duplicate: () => {},
|
||||
onError: () => {},
|
||||
onDragCancel: (callback) => {
|
||||
cancel = callback
|
||||
subscribed = true
|
||||
return () => (subscribed = false)
|
||||
},
|
||||
})
|
||||
return null
|
||||
}, document.createElement("div"))
|
||||
await Promise.resolve()
|
||||
|
||||
cancel()
|
||||
|
||||
expect(dragging).toBeNull()
|
||||
dispose()
|
||||
expect(subscribed).toBeFalse()
|
||||
})
|
||||
@@ -128,7 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
bytecode: true,
|
||||
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
|
||||
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
|
||||
bytecode: false,
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -84,15 +83,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify, signal) =>
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -164,21 +163,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "auto"
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return policy
|
||||
return "notify"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,14 +6,14 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
@@ -23,11 +23,6 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("automatically installs every available release when enabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("skips when updates are disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export interface Interface {
|
||||
readonly run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
@@ -75,10 +75,10 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
@@ -168,20 +168,17 @@ const make = Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
@@ -203,19 +200,18 @@ const make = Effect.gen(function* () {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current,
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(current, version, policy)
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
|
||||
return { policy, version }
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
@@ -224,10 +220,8 @@ const make = Effect.gen(function* () {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
yield* upgrade(detected, version)
|
||||
yield* Ref.set(installedVersion, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -235,36 +229,9 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (OPENCODE_LOCAL)
|
||||
return {
|
||||
type: "unavailable" as const,
|
||||
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
|
||||
}
|
||||
const version = yield* latest()
|
||||
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
if (action(current, version, "auto") === "none") {
|
||||
// An earlier check may have installed the update while this client is still running.
|
||||
return action(OPENCODE_VERSION, current, "auto") === "none"
|
||||
? undefined
|
||||
: { type: "installed" as const, version: current }
|
||||
}
|
||||
return { type: "available" as const, version }
|
||||
})
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (!result) return undefined
|
||||
if (result.policy === "notify") return { type: "available" as const, version: result.version }
|
||||
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
return { type: "installed" as const, version: result.version }
|
||||
},
|
||||
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
|
||||
return Service.of({ run, check, apply, method, latest, upgrade })
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,8 +12,7 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
|
||||
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1892,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify" | "auto"
|
||||
update?: "disable" | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
ConfigEntry,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
@@ -85,7 +84,6 @@ type LocationData = {
|
||||
vcs?: VcsInfo
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
config?: ConfigEntry[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcpServer?: McpServer[]
|
||||
mcpResource?: McpResource[]
|
||||
@@ -1217,11 +1215,6 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
break
|
||||
case "config.updated":
|
||||
result.location.config.invalidate(location)
|
||||
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
|
||||
refresh(() => result.location.config.sync(location))
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
case "websearch.updated":
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
@@ -1797,7 +1790,6 @@ export function createData(config: CreateDataInput) {
|
||||
result.location.vcs.invalidate(location)
|
||||
result.location.agent.invalidate(location)
|
||||
result.location.command.invalidate(location)
|
||||
result.location.config.invalidate(location)
|
||||
result.location.integration.invalidate(location)
|
||||
result.location.mcp.server.invalidate(location)
|
||||
result.location.mcp.resource.invalidate(location)
|
||||
@@ -1811,10 +1803,6 @@ export function createData(config: CreateDataInput) {
|
||||
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
||||
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
||||
command: locationResource("command", (location) => api().command.list({ location })),
|
||||
config: locationResource("config", async (location) => ({
|
||||
location: { directory: location.directory, workspaceID: location.workspace },
|
||||
data: await api().config.get({ location }),
|
||||
})),
|
||||
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
||||
mcp: {
|
||||
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
||||
|
||||
@@ -3,56 +3,6 @@ import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("config reads and update refreshes are opt-in", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: string[] = []
|
||||
const location = { directory: "/project" }
|
||||
let model = "provider/first"
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/api/location") return Response.json(location)
|
||||
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return Response.json({ location, data: { resources: [], templates: [] } })
|
||||
return Response.json({ location, data: [] })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
|
||||
try {
|
||||
await setup.data.location.sync()
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
expect(requests).not.toContain("/api/config")
|
||||
|
||||
await setup.data.location.config.sync()
|
||||
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
|
||||
model = "provider/second"
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
await setup.data.location.config.sync()
|
||||
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
|
||||
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const reported = Promise.withResolvers<unknown>()
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.4.1
|
||||
ARG BUN_VERSION=1.3.14
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -41,3 +41,37 @@ const result = await Bun.build({
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
|
||||
|
||||
// Bun's Node target eagerly creates its shared require helper, so every split
|
||||
// entry evaluates import.meta.url even when it never requires a module. Keep
|
||||
// the helper lazy until Bun stops hoisting it into workerd-reachable chunks.
|
||||
// https://github.com/oven-sh/bun/issues/12615
|
||||
const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);"
|
||||
const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier);
|
||||
__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);`
|
||||
const rewritten = await Promise.all(
|
||||
result.outputs.map(async (output) => {
|
||||
if (!output.path.endsWith(".js")) return false
|
||||
const source = await output.text()
|
||||
|
||||
const generatedUses = source
|
||||
.replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "")
|
||||
.replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "")
|
||||
.replace(eagerRequire, "")
|
||||
if (/\bnew\s+__require\s*\(/.test(generatedUses))
|
||||
throw new Error(`Unsupported generated require constructor in ${output.path}`)
|
||||
const unsupported = generatedUses.replace(/\b__require\.resolve\s*\(/g, "").replace(/\b__require\s*\(/g, "")
|
||||
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
|
||||
|
||||
if (!source.includes(eagerRequire)) return false
|
||||
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
|
||||
throw new Error(`Multiple eager require helpers in ${output.path}`)
|
||||
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
|
||||
if (rewrittenSource.includes(eagerRequire))
|
||||
throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
|
||||
await Bun.write(output.path, rewrittenSource)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (rewritten.filter(Boolean).length !== 1)
|
||||
throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable")
|
||||
|
||||
@@ -74,7 +74,9 @@ export function normalize(input: unknown): Result {
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
|
||||
@@ -409,17 +409,19 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
const steer = (yield* pendingSteers(db, sessionID))[0]
|
||||
const next = (delivery: Delivery) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const steer = yield* next("steer")
|
||||
if (steer) return fromRow(steer)
|
||||
if (promotable !== "input") return undefined
|
||||
const queued = yield* db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const queued = yield* next("queue")
|
||||
return queued ? fromRow(queued) : undefined
|
||||
})
|
||||
|
||||
@@ -488,10 +490,9 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
})
|
||||
|
||||
/**
|
||||
* Promotes pending input into visible messages and returns the promoted count,
|
||||
* or undefined when the runner must first handle a pending control.
|
||||
* Steered compaction takes priority over pending prompts, without crossing a move.
|
||||
* Only the "input" scope may fall through to one queued input.
|
||||
* Promotes pending input into visible messages and returns the promoted count.
|
||||
* Steers always go first; only the "input" scope may fall through to one queued
|
||||
* input, and it then collects steers that arrived during promotion.
|
||||
*/
|
||||
export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -505,7 +506,6 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
const steers = yield* pendingSteers(db, sessionID)
|
||||
if (steers.length > 0 || scope === "steer") {
|
||||
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control === 0) return undefined
|
||||
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
|
||||
}
|
||||
|
||||
@@ -518,7 +518,6 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!queued) return 0
|
||||
if (queued.type === "compaction" || queued.type === "move") return undefined
|
||||
const promoted = yield* publish(db, bus, sessionID, [queued])
|
||||
const arrivedSteers = yield* pendingSteers(db, sessionID)
|
||||
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
@@ -537,14 +536,4 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => {
|
||||
// A move changes the context's Location: never pull compaction across it.
|
||||
// Within that boundary, compact before promoting even earlier steers so
|
||||
// their text stays verbatim after the checkpoint, not inside its summary.
|
||||
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
|
||||
return rows
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -147,7 +147,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
const ready = yield* restore(
|
||||
return yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
@@ -156,8 +156,6 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
// A control admitted during context preparation owns this boundary.
|
||||
if (promoted === undefined) return undefined
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
onlyIfMissing: true,
|
||||
@@ -166,7 +164,6 @@ const layer = Layer.effect(
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
if (ready) return ready
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -44,7 +44,7 @@ export function lockLinux(fd: number): LockResult {
|
||||
}
|
||||
}
|
||||
|
||||
function errorCode(pointer: Pointer | bigint | null) {
|
||||
function errorCode(pointer: Pointer | null) {
|
||||
if (pointer === null) throw new Error("Failed to read process lock error code")
|
||||
return read.i32(pointer, 0)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
? "notify"
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
@@ -666,14 +666,14 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the native auto update policy", () => {
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "auto" },
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface WebSocketServerFixture {
|
||||
}
|
||||
|
||||
export interface WebSocketServerOptions {
|
||||
readonly http?: (request: Request) => Response | Promise<Response>
|
||||
readonly upgrade?: (request: Request) => boolean
|
||||
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
|
||||
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
|
||||
@@ -35,7 +34,6 @@ export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
state.headers.push(Object.fromEntries(request.headers.entries()))
|
||||
if (request.headers.get("upgrade") !== "websocket" && options.http) return options.http(request)
|
||||
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
|
||||
return undefined
|
||||
return new Response("WebSocket upgrade required", {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLM, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { configure } from "@opencode-ai/ai/providers/openai"
|
||||
import { SessionModelTransport } from "../src/session/model-transport"
|
||||
import { WebSocketConstructor } from "../src/effect/websocket-constructor"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { makeWebSocketServer } from "./lib/websocket-server"
|
||||
|
||||
const runtime = Layer.mergeAll(
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.provide(FetchHttpClient.layer)),
|
||||
SessionModelTransport.layer.pipe(Layer.provide(WebSocketConstructor.layer)),
|
||||
)
|
||||
const it = testEffect(runtime)
|
||||
const sessionID = Session.ID.make("ses_checkpoint_transport")
|
||||
const checkpoint = { type: "compaction", encrypted_content: "opaque" }
|
||||
const fixture = (mode: "success" | "invalid" | "fallback") =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Array<Record<string, unknown>> = []
|
||||
const http: Array<Record<string, unknown>> = []
|
||||
let disconnect: (() => void) | undefined
|
||||
const server = yield* makeWebSocketServer({
|
||||
upgrade: () => mode !== "fallback" || disconnect === undefined,
|
||||
async http(request) {
|
||||
http.push(Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))(await request.json()))
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_http",
|
||||
output: [checkpoint],
|
||||
usage: { input_tokens: 70, output_tokens: 7 },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream", "x-response": "http" } },
|
||||
)
|
||||
},
|
||||
open(socket) {
|
||||
disconnect = () => socket.close()
|
||||
},
|
||||
message(socket, message) {
|
||||
const body = JSON.parse(message.toString())
|
||||
requests.push(body)
|
||||
const id = `resp_${requests.length}`
|
||||
const send = (event: unknown) => socket.send(JSON.stringify(event))
|
||||
if (body.input.at(-1)?.type !== "compaction_trigger") {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: `msg_${requests.length}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
}
|
||||
send({ type: "response.created", response: { id } })
|
||||
send({ type: "response.output_item.added", output_index: 0, item })
|
||||
send({ type: "response.output_text.delta", item_id: item.id, delta: "Hello" })
|
||||
send({ type: "response.output_item.done", output_index: 0, item })
|
||||
send({ type: "response.completed", response: { id, output: [item] } })
|
||||
return
|
||||
}
|
||||
send({ type: "response.created", response: { id } })
|
||||
send({ type: "response.output_item.done", output_index: 0, item: checkpoint })
|
||||
send({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id,
|
||||
output: mode === "invalid" ? [checkpoint, { ...checkpoint, encrypted_content: "second" }] : [checkpoint],
|
||||
usage: { input_tokens: 100, output_tokens: 10 },
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return {
|
||||
requests,
|
||||
headers: server.state.headers,
|
||||
http,
|
||||
opens: () => server.state.opens,
|
||||
disconnect: () => disconnect?.(),
|
||||
request: LLM.request({
|
||||
model: configure({
|
||||
apiKey: "fixture",
|
||||
baseURL: server.url.replace(/^ws/, "http").replace(/responses$/, ""),
|
||||
headers: { "chatgpt-account-id": "account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
providerOptions: { parallelToolCalls: true },
|
||||
}).responses("fixture"),
|
||||
prompt: "First",
|
||||
promptCacheKey: "session-key",
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
it.live("trigger reuses the append baseline and clears it before the next generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("success")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
const compacted = yield* LLMClient.compact(
|
||||
LLMRequest.update(server.request, {
|
||||
messages: [...server.request.messages, first.message],
|
||||
}),
|
||||
{ mechanism: "trigger", webSocket },
|
||||
)
|
||||
expect(server.requests[1]).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "compaction_trigger" }],
|
||||
})
|
||||
expect(compacted.responseID).toBe("resp_2")
|
||||
expect(compacted.usage).toMatchObject({ inputTokens: 100, outputTokens: 10 })
|
||||
expect(compacted.checkpoint.encrypted).toBe("opaque")
|
||||
const messages = [Message.assistant(compacted.checkpoint), Message.user("Continue")]
|
||||
yield* LLMClient.generate(LLMRequest.update(server.request, { messages }), { webSocket })
|
||||
expect(server.requests[2]?.previous_response_id).toBeUndefined()
|
||||
expect(server.requests[2]?.input).toMatchObject([
|
||||
{ type: "compaction", encrypted_content: "opaque" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
expect(server.requests[1]?.stream).toBeUndefined()
|
||||
expect(server.requests[1]?.store).toBe(false)
|
||||
expect(server.headers[0]).toMatchObject({
|
||||
authorization: "Bearer fixture",
|
||||
"chatgpt-account-id": "account",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
})
|
||||
expect(server.opens()).toBe(1)
|
||||
expect(server.http).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("invalid trigger output does not retry or commit a continuation checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("invalid")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
const input = LLMRequest.update(server.request, { messages: [...server.request.messages, first.message] })
|
||||
const error = yield* LLMClient.compact(input, { mechanism: "trigger", webSocket }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(server.requests).toHaveLength(2)
|
||||
expect(server.http).toHaveLength(0)
|
||||
yield* LLMClient.generate(input, { webSocket })
|
||||
expect(server.requests[2]?.previous_response_id).toBeUndefined()
|
||||
expect(server.requests[2]?.input).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("trigger recovers over SSE with complete input after the old socket closes and reconnect is rejected", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture("fallback")
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const webSocket = transport.bind(sessionID)
|
||||
const first = yield* LLMClient.generate(server.request, { webSocket })
|
||||
server.disconnect()
|
||||
// Let the real close event reach the connector before the next send.
|
||||
yield* Effect.sleep("20 millis")
|
||||
const result = yield* LLMClient.compact(
|
||||
LLMRequest.update(server.request, {
|
||||
messages: [...server.request.messages, first.message],
|
||||
}),
|
||||
{ mechanism: "trigger", webSocket },
|
||||
)
|
||||
expect(result.responseID).toBe("resp_http")
|
||||
expect(result.usage).toMatchObject({ inputTokens: 70, outputTokens: 7 })
|
||||
expect(server.http).toHaveLength(1)
|
||||
expect(server.http[0]?.previous_response_id).toBeUndefined()
|
||||
expect(server.http[0]?.stream).toBe(true)
|
||||
expect(server.http[0]?.input).toHaveLength(3)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
@@ -74,34 +74,23 @@ describe("SessionModelTransport local WebSocket server", () => {
|
||||
const index = requests.length
|
||||
const id = `msg_${index}`
|
||||
const text = index === 1 ? "Hello" : "Brief"
|
||||
const reasoning = { type: "reasoning", id: `rs_${index}`, summary: [], encrypted_content: "stream-encrypted" }
|
||||
const item = {
|
||||
type: "message",
|
||||
id,
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text, annotations: [], logprobs: [] }],
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "response.created", response: { id: `resp_${index}` } }))
|
||||
socket.send(JSON.stringify({ type: "response.output_item.done", item: reasoning }))
|
||||
socket.send(JSON.stringify({ type: "response.output_item.added", item: { type: "message", id } }))
|
||||
socket.send(JSON.stringify({ type: "response.output_text.delta", item_id: id, delta: text }))
|
||||
socket.send(JSON.stringify({ type: "response.output_text.done", item_id: id, text }))
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "response.output_item.done",
|
||||
item,
|
||||
}),
|
||||
)
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp_${index}`,
|
||||
output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }, item],
|
||||
item: {
|
||||
type: "message",
|
||||
id,
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${index}` } }))
|
||||
},
|
||||
},
|
||||
(server) =>
|
||||
@@ -130,7 +119,7 @@ describe("SessionModelTransport local WebSocket server", () => {
|
||||
llm.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("First"), first.message, Message.user("Be brief")],
|
||||
messages: [Message.user("First"), Message.assistant("Hello"), Message.user("Be brief")],
|
||||
}),
|
||||
{ webSocket: executor },
|
||||
),
|
||||
|
||||
@@ -1925,185 +1925,6 @@ describe("SessionRunnerLLM", () => {
|
||||
).toEqual(["Replacement context"])
|
||||
})
|
||||
|
||||
for (const order of ["before", "between", "after"] as const) {
|
||||
scenario(`prioritizes manual compaction admitted ${order} two steers at the safe boundary`, function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "active"),
|
||||
TestLLM.text("## Objective\n- Active work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const compactID = SessionMessage.ID.create()
|
||||
if (order === "before") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const first = yield* s.admit("STEER_A")
|
||||
if (order === "between") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const second = yield* s.admit("STEER_B")
|
||||
if (order === "after") yield* s.session.compact({ sessionID, id: compactID })
|
||||
expect((yield* s.session.compact({ sessionID })).id).toBe(compactID)
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* s.inbox).toHaveLength(3)
|
||||
expect((yield* s.messages).some((message) => message.type === "compaction")).toBe(false)
|
||||
yield* active.finish
|
||||
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_A")
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_B")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
expect((yield* s.messages).filter((message) => message.id === compactID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed" },
|
||||
])
|
||||
expect((yield* s.context).filter((message) => message.type === "user").map((message) => message.id)).toEqual([
|
||||
first.id,
|
||||
second.id,
|
||||
])
|
||||
// An advisory drain must not redeliver either steer or rerun compaction.
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false })
|
||||
expect(s.requests).toHaveLength(3)
|
||||
})
|
||||
}
|
||||
|
||||
scenario("waits for active tools before prioritizing compaction over pending steers", function* (s) {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.text("## Objective\n- Tool work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const tools = yield* s.blockTools()
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests[1].messages.some((message) => message.role === "tool")).toBe(true)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("rechecks compaction admitted during boundary context preparation", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const preparing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
s.systemLoadHook = Deferred.succeed(preparing, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("## Objective\n- Earlier work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(preparing)
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
for (const outcome of ["cancelled", "failed"] as const) {
|
||||
scenario(`preserves both earlier steers when prioritized compaction is ${outcome}`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Active complete", "active"))
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
if (outcome === "cancelled") yield* s.session.cancelInbox({ sessionID, inboxID: compact.id })
|
||||
if (outcome === "failed") yield* s.llm.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
||||
yield* s.llm.push(TestLLM.text("Steers complete", "steers"))
|
||||
yield* active.finish
|
||||
|
||||
expect(s.requests).toHaveLength(outcome === "cancelled" ? 2 : 3)
|
||||
if (outcome === "failed") {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
})
|
||||
}
|
||||
if (outcome === "cancelled") expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
expect(userTexts(s.requests[s.requests.length - 1]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(
|
||||
(yield* s.context)
|
||||
.filter((message) => message.id === first.id || message.id === second.id)
|
||||
.map((message) => message.id),
|
||||
).toEqual([first.id, second.id])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
scenario("keeps steers durable across interrupted priority compaction and replay", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- Interrupted checkpoint", "summary"))
|
||||
const summary = yield* s.llm.gate
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* summary.started
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
yield* s.session.interrupt(sessionID)
|
||||
yield* s.session.wait(sessionID)
|
||||
yield* summary.release
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({ status: "failed" })
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
|
||||
yield* s.llm.push(TestLLM.text("Recovered steers", "steers"))
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("does not pull compaction across an earlier move", function* (s) {
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* s.admit("STEER_B")
|
||||
yield* s.sessionInbox.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "steer" })
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("First steer complete", "first"),
|
||||
TestLLM.text("## Objective\n- Source work checkpoint", "summary"),
|
||||
TestLLM.text("Second steer complete", "second"),
|
||||
)
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[0])).toEqual(["STEER_A"])
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).at(-1)).toBe("STEER_B")
|
||||
expect(
|
||||
(yield* recordedEventTypes(sessionID)).filter(
|
||||
(type) => type === "session.moved.1" || type === "session.compaction.started.1",
|
||||
),
|
||||
).toEqual(["session.moved.1", "session.compaction.started.1"])
|
||||
})
|
||||
|
||||
scenario("runs steers before queued compaction and later queued input", function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { app, BrowserWindow, MessageChannelMain } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { RpcServer } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs } from "../shared/ipc-rpc"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { DesktopFiles, openExternalURL } from "./files"
|
||||
import { appHandlers } from "./ipc-handlers/app"
|
||||
import { eventHandlers } from "./ipc-handlers/events"
|
||||
@@ -59,10 +59,6 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
relaunch: lifecycle.relaunch,
|
||||
}
|
||||
const wire = (_event: Electron.Event, win: BrowserWindow) => {
|
||||
win.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { windowIDFromArguments } from "../shared/window-bootstrap"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
@@ -7,8 +7,6 @@ ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
if (port) window.postMessage(IpcTransportPort, "*", [port])
|
||||
})
|
||||
|
||||
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
windowID: windowIDFromArguments(process.argv),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
|
||||
import { windowFullscreen } from "../window/fullscreen"
|
||||
import { DragCancelEvent } from "../../shared/ipc-transport"
|
||||
import { createDesktopFiles } from "./files"
|
||||
import { createDesktopMenuAction } from "./menu"
|
||||
import { createDesktopNotify } from "./notifications"
|
||||
@@ -54,10 +53,6 @@ export function createDesktopPlatform(
|
||||
windowFullscreen,
|
||||
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
|
||||
setPinchZoomEnabled,
|
||||
onDragCancel: (callback) => {
|
||||
window.addEventListener(DragCancelEvent, callback)
|
||||
return () => window.removeEventListener(DragCancelEvent, callback)
|
||||
},
|
||||
runDesktopMenuAction: createDesktopMenuAction(api),
|
||||
checkAppExists: async (appName) => {
|
||||
return api.checkAppExists(appName)
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const IpcTransportPort = "desktop-rpc-port"
|
||||
export const DragCancelEvent = "opencode:drag-cancel"
|
||||
|
||||
@@ -162,21 +162,6 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
/** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
readonly width: number
|
||||
readonly presentation: PanelPresentation
|
||||
readonly focused: boolean
|
||||
readonly focus: () => void
|
||||
readonly close: () => void
|
||||
readonly toggleFullscreen: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
@@ -195,7 +180,6 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -466,14 +450,6 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly panel: {
|
||||
/** Opens the session.panel slot in the current session. */
|
||||
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
|
||||
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
|
||||
close(): void
|
||||
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
|
||||
current(): { readonly name: string; readonly sessionID: string } | undefined
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,12 +18221,6 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18454,9 +18448,6 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install updates automatically",
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -216,6 +216,9 @@ for (const module of modules) {
|
||||
|
||||
const transpiler = new Bun.Transpiler({ loader: "js" })
|
||||
const bundled = await Bun.file(join(consumer, "dist/worker.js")).text()
|
||||
if (/createRequire\s*\(\s*import\.meta\.url\s*\)/.test(bundled)) {
|
||||
throw new Error("Packed workerd bundle contains Bun's eager Node require initializer")
|
||||
}
|
||||
const bunGlobals = Array.from(new Set(bundled.match(/\bBun\.[A-Za-z_$][\w$]*/g) ?? []))
|
||||
if (bunGlobals.length > 0) throw new Error(`Packed workerd bundle references Bun globals: ${bunGlobals.join(", ")}`)
|
||||
const leaked = [
|
||||
@@ -227,8 +230,6 @@ for (const module of modules) {
|
||||
].filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
|
||||
if (leaked.length > 0) throw new Error(`Packed workerd bundle statically imports Bun builtins: ${leaked.join(", ")}`)
|
||||
|
||||
// Boot in workerd to catch eager Node initializers; lazy createRequire calls
|
||||
// in native-only code are valid and must not fail a bundle-wide text check.
|
||||
await $`node boot.mjs`.cwd(consumer)
|
||||
console.log("packed SDK consumer OK")
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
@@ -116,14 +114,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -101,13 +101,7 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
const reader = body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* server.updated("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.updated"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -132,11 +126,3 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`file tools share Patch's upfront file list in ${theme}`, async ({ mount }, info) => {
|
||||
const root = await mount("current-session-research-agents--agent-research", {
|
||||
args: { scenario: "workflow" },
|
||||
globals: { theme },
|
||||
})
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]').filter({ hasText: "Patch" })
|
||||
const disclosure = group.getByRole("button", { name: /^Used .*Edit.*Write.*Patch$/ })
|
||||
await disclosure.click()
|
||||
for (const name of ["edit", "write", "patch"]) {
|
||||
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
|
||||
const file = tool.locator('[data-slot="accordion-trigger"]')
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(tool.locator('[data-component="file"]')).toHaveCount(0)
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(tool.locator('[data-component="file"]')).toBeVisible()
|
||||
await expect(file).toBeFocused()
|
||||
await file.press("Space")
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await file.press("Enter")
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
await disclosure.click()
|
||||
await disclosure.click()
|
||||
for (const name of ["edit", "write", "patch"]) {
|
||||
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
|
||||
await expect(tool.locator('[data-slot="accordion-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(tool.locator('[data-component="file"]')).toBeVisible()
|
||||
}
|
||||
await root.screenshot({ path: info.outputPath(`file-tools-${theme}.png`) })
|
||||
})
|
||||
}
|
||||
|
||||
for (const tool of ["edit", "write"]) {
|
||||
for (const controlled of [false, true]) {
|
||||
story(`${tool} supports forceOpen with ${controlled ? "controlled" : "local"} disclosure`, async ({ mount }) => {
|
||||
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
|
||||
args: { tool, controlled, forceOpen: true },
|
||||
})
|
||||
await expect(root.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(root.locator('[data-component="file"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
story(`${tool} preserves input fallback and disclosure through completion`, async ({ mount }) => {
|
||||
const root = await mount("current-session-file-changes--file-tool-fallbacks", { args: { tool } })
|
||||
const file = root.getByRole("button", { name: /example\.ts/ })
|
||||
await expect(file).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(root.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await file.click()
|
||||
await expect(root.locator('[data-component="file"]')).toContainText(tool === "edit" ? "after" : "written")
|
||||
await root.getByRole("button", { name: "Complete file tool" }).click()
|
||||
await expect(root.getByText("Example diagnostic")).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(root.getByText("Example diagnostic")).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
story("empty writes still show a file row", async ({ mount }) => {
|
||||
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
|
||||
args: { tool: "write", empty: true },
|
||||
})
|
||||
await root.getByRole("button", { name: "Complete file tool" }).click()
|
||||
const file = root.getByRole("button", { name: /example\.ts/ })
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(root.locator('[data-component="file"]')).toBeAttached()
|
||||
await expect(file).toBeVisible()
|
||||
})
|
||||
@@ -25,17 +25,18 @@ story("renders every admitted tool family and hides timeline-only exclusions", a
|
||||
]) {
|
||||
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
|
||||
}
|
||||
for (const name of ["edit", "write", "patch"]) {
|
||||
const tool = timeline.locator(`[data-timeline-part-id="tool_family_${name}"]`)
|
||||
await expect(tool.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(tool.getByRole("button")).toHaveCount(1)
|
||||
await expect(tool.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("data-locked", "")
|
||||
await expect(tool.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("font-size", "13px")
|
||||
await expect(tool.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("line-height", "16px")
|
||||
}
|
||||
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
|
||||
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
|
||||
await expect(patch.getByRole("button")).toHaveCount(1)
|
||||
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("font-size", "13px")
|
||||
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
|
||||
await expect(edit).toContainText("Edit")
|
||||
await expect(edit.locator('[data-slot="message-part-title"]')).toHaveCSS("font-size", "13px")
|
||||
await expect(edit.locator('[data-slot="message-part-title"]')).toHaveCSS("line-height", "16px")
|
||||
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -466,6 +466,102 @@
|
||||
--tool-content-gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="edit-trigger"],
|
||||
[data-component="write-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
[data-slot="message-part-title-area"] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-title"] {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-spinner"] {
|
||||
margin-left: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-text"] {
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-filename"] {
|
||||
/* No text-transform - preserve original filename casing */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--font-weight-regular);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
[data-slot="message-part-path"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
font-weight: var(--font-weight-regular);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
[data-slot="message-part-directory"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="message-part-filename"] {
|
||||
color: var(--v2-text-text-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-content"] {
|
||||
border-radius: inherit;
|
||||
border-top: 0.5px solid var(--v2-border-border-muted);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "../storybook/current-session-fixtures"
|
||||
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { SessionTimeline } from "./session-timeline"
|
||||
import { ToolDisplay } from "../tools/tool-renderer"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Work/File changes",
|
||||
@@ -262,48 +261,3 @@ export const CreatedANewFile = {
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const FileToolFallbacks = {
|
||||
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
|
||||
argTypes: {
|
||||
tool: { control: "select", options: ["edit", "write"] },
|
||||
empty: { control: "boolean" },
|
||||
forceOpen: { control: "boolean" },
|
||||
controlled: { control: "boolean" },
|
||||
},
|
||||
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
|
||||
const [state, setState] = createStore({ completed: false, open: false })
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setState("completed", true)}>
|
||||
Complete file tool
|
||||
</button>
|
||||
<CurrentSessionProviders document={storyDocument([])}>
|
||||
<ToolDisplay
|
||||
id="tool_file_fallback"
|
||||
tool={args.tool}
|
||||
status={state.completed ? "completed" : "running"}
|
||||
input={{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
}}
|
||||
metadata={{
|
||||
diagnostics: state.completed
|
||||
? {
|
||||
"src/example.ts": [
|
||||
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
open={args.controlled ? state.open : undefined}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
forceOpen={args.forceOpen}
|
||||
/>
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -528,12 +528,12 @@ export function CurrentContextToolGroup(props: {
|
||||
.join(", "),
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
|
||||
if (!names() && !thoughts) {
|
||||
const title = i18n.t("ui.messagePart.context.details")
|
||||
return { text: title, title, before: "", after: "" }
|
||||
}
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const notices = props.parts.filter((part) => part.type === "notice").length
|
||||
const title =
|
||||
[names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
|
||||
.filter(Boolean)
|
||||
.join(", ") ||
|
||||
i18n.plural("ui.messagePart.context.thought", props.parts.filter((part) => part.type === "reasoning").length)
|
||||
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
|
||||
@@ -951,66 +951,20 @@ export const ToolRegistry = {
|
||||
render: getTool,
|
||||
}
|
||||
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
open
|
||||
onOpenChange={undefined}
|
||||
locked
|
||||
icon="code-lines"
|
||||
defer={false}
|
||||
rail={false}
|
||||
trigger={{
|
||||
title: props.title,
|
||||
subtitle: props.count > 0 ? `${props.count} ${i18n.plural("ui.common.file", props.count)}` : "",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</BasicTool>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolFileAccordion(props: {
|
||||
path: string
|
||||
actions?: JSX.Element
|
||||
children: JSX.Element
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
}) {
|
||||
const value = createMemo(() => props.path || "tool-file")
|
||||
const [expanded, setExpanded] = createSignal(props.defaultOpen ?? false)
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
const open = () => props.open ?? expanded()
|
||||
const change = (value: boolean) => {
|
||||
if (props.open === undefined) setExpanded(value)
|
||||
props.onOpenChange?.(value)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.forceOpen && !open()) change(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
const frame = requestAnimationFrame(() => setVisible(true))
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
})
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||
value={open() ? [value()] : []}
|
||||
onChange={(next) => change(next.includes(value()))}
|
||||
defaultValue={props.defaultOpen === false ? [] : [value()]}
|
||||
>
|
||||
<Accordion.Item value={value()}>
|
||||
<StickyAccordionHeader>
|
||||
@@ -1032,9 +986,7 @@ function ToolFileAccordion(props: {
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={!props.defer || visible()}>{props.children}</Show>
|
||||
</Accordion.Content>
|
||||
<Accordion.Content>{props.children}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)
|
||||
@@ -1757,6 +1709,7 @@ ToolRegistry.register({
|
||||
const value = diff()
|
||||
return typeof value?.file === "string" ? value.file : inputPath()
|
||||
})
|
||||
const filename = () => getFilename(inputPath())
|
||||
const pending = () => props.status === "streaming" || props.status === "running"
|
||||
const diffSource = createMemo(
|
||||
() => {
|
||||
@@ -1796,15 +1749,39 @@ ToolRegistry.register({
|
||||
|
||||
return (
|
||||
<div data-component="edit-tool">
|
||||
<FileTool {...props} title={i18n.t("ui.messagePart.title.edit")} count={path() ? 1 : 0}>
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{filename()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && inputPath().includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{displayDirectory(inputPath())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<Show when={!pending() ? diff() : undefined}>
|
||||
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
actions={
|
||||
<Show when={!pending() ? diff() : undefined}>
|
||||
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
|
||||
@@ -1823,7 +1800,7 @@ ToolRegistry.register({
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
</FileTool>
|
||||
</BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -1837,18 +1814,38 @@ ToolRegistry.register({
|
||||
const path = createMemo(() => (typeof props.input.path === "string" ? props.input.path : ""))
|
||||
const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : ""))
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path()))
|
||||
const filename = () => getFilename(path())
|
||||
const pending = () => props.status === "streaming" || props.status === "running"
|
||||
return (
|
||||
<div data-component="write-tool">
|
||||
<FileTool {...props} title={i18n.t("ui.messagePart.title.write")} count={path() ? 1 : 0}>
|
||||
<Show when={path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{filename()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && path().includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{displayDirectory(path())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={content() && path()}>
|
||||
<ToolFileAccordion path={path()}>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
@@ -1865,7 +1862,7 @@ ToolRegistry.register({
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
</FileTool>
|
||||
</BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -1894,9 +1891,27 @@ ToolRegistry.register({
|
||||
files().forEach((file) => props.onFileOpenChange?.(file.path, next.includes(file.path)))
|
||||
}
|
||||
|
||||
const subtitle = createMemo(() => {
|
||||
const count = files().length
|
||||
if (count === 0) return ""
|
||||
return `${count} ${i18n.plural("ui.common.file", count)}`
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="apply-patch-tool">
|
||||
<FileTool {...props} title={title()} count={files().length}>
|
||||
<BasicTool
|
||||
{...props}
|
||||
open
|
||||
onOpenChange={undefined}
|
||||
locked
|
||||
icon="code-lines"
|
||||
defer={false}
|
||||
rail={false}
|
||||
trigger={{
|
||||
title: title(),
|
||||
subtitle: subtitle(),
|
||||
}}
|
||||
>
|
||||
<Show when={files().length > 0}>
|
||||
<Accordion
|
||||
multiple
|
||||
@@ -1985,7 +2000,7 @@ ToolRegistry.register({
|
||||
</Index>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</FileTool>
|
||||
</BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.4.1-alpine AS base
|
||||
FROM oven/bun:1.3.14-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+56
-54
@@ -64,6 +64,7 @@ import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { DialogUpdate } from "./component/dialog-update"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
@@ -87,7 +88,6 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
|
||||
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
|
||||
import { localPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
@@ -100,7 +100,6 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -155,7 +154,6 @@ const appBindingCommands = [
|
||||
"provider.connect",
|
||||
"opencode.settings",
|
||||
"opencode.status",
|
||||
"opencode.update",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
"opencode.debug",
|
||||
@@ -187,7 +185,10 @@ export type TuiInput = {
|
||||
}
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: UpdateSource
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageSource
|
||||
environment?: Readonly<Record<string, string>>
|
||||
terminalHandoff?: () => Promise<
|
||||
@@ -396,27 +397,22 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<UpdateNotificationProvider
|
||||
updater={input.updater}
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
</UpdateNotificationProvider>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -466,7 +462,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -478,12 +474,10 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panels = usePanel()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const updater = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const data = useData()
|
||||
@@ -507,6 +501,40 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
|
||||
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
|
||||
})
|
||||
const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>(
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -580,22 +608,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const fullscreenPanel = () =>
|
||||
route.data.type === "session" &&
|
||||
panels.current()?.sessionID === route.data.sessionID &&
|
||||
panels.presentation() === "fullscreen"
|
||||
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
// Measure the prospective split layout, even while full-screen hides the tabs.
|
||||
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
|
||||
createEffect(() => {
|
||||
const current = panels.current()
|
||||
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
|
||||
panels.close()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
@@ -722,7 +737,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
const model = local.model.current()
|
||||
const agent = local.agent.current()
|
||||
const current =
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
@@ -736,7 +750,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
location.error?.location,
|
||||
),
|
||||
})
|
||||
if (agent) local.agent.set(agent.id)
|
||||
if (model) local.model.set(model)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -957,17 +970,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(updater.open
|
||||
? [
|
||||
{
|
||||
name: "opencode.update",
|
||||
title: "Update OpenCode",
|
||||
slash: { name: "update" },
|
||||
run: () => updater.open?.("manual"),
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "server.pair",
|
||||
title: "Pair device",
|
||||
|
||||
@@ -1,56 +1,67 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import type { UpdateState } from "../context/update-notification"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: {
|
||||
check?: (signal: AbortSignal) => Promise<string | undefined>
|
||||
state: () => UpdateState | undefined
|
||||
dialogKey: string
|
||||
version: string
|
||||
install: () => Promise<void>
|
||||
restart: () => void
|
||||
restart?: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
const [active, setActive] = createSignal(0)
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
|
||||
dialog.setCentered(true)
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const [check] = createResource(
|
||||
() => props.check,
|
||||
(check) =>
|
||||
check(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(error))
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
const state = createMemo(() => {
|
||||
if (check.loading) return { type: "checking" as const }
|
||||
const unavailable = check()
|
||||
if (unavailable) return { type: "unavailable" as const, message: unavailable }
|
||||
const message = error()
|
||||
if (message) return { type: "check-failed" as const, message }
|
||||
return props.state() ?? { type: "current" as const }
|
||||
})
|
||||
const buttons = createMemo(() => {
|
||||
const type = state().type
|
||||
if (type === "installing") return []
|
||||
const confirm =
|
||||
type === "available"
|
||||
? { label: "Update", run: props.install }
|
||||
: type === "installed"
|
||||
? { label: "Restart", run: props.restart }
|
||||
: undefined
|
||||
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
|
||||
})
|
||||
const beginInstall = () => {
|
||||
if (state().type !== "ready") return
|
||||
void install().catch((error) => setState({ type: "failed", message: errorMessage(error) }))
|
||||
}
|
||||
|
||||
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
|
||||
const run = () => {
|
||||
const current = state()
|
||||
if (current.type !== "ready") return
|
||||
if (current.active === "skip") return close()
|
||||
beginInstall()
|
||||
}
|
||||
|
||||
const toggle = () =>
|
||||
setState((current) =>
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
|
||||
)
|
||||
|
||||
const selected = (action: "update" | "skip") => {
|
||||
const current = state()
|
||||
return current.type === "ready" && current.active === action
|
||||
}
|
||||
|
||||
const failure = () => {
|
||||
const current = state()
|
||||
return current.type === "failed" ? current.message : ""
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
@@ -59,17 +70,20 @@ export function DialogUpdate(props: {
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => void buttons()[active()]?.run(),
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
},
|
||||
...["left", "right", "tab", "shift+tab"].map((bind) => ({
|
||||
bind,
|
||||
title: bind === "left" || bind === "shift+tab" ? "Previous update action" : "Next update action",
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous update action",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
const count = buttons().length
|
||||
if (count) setActive((value) => (value + 1) % count)
|
||||
},
|
||||
})),
|
||||
run: toggle,
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next update action",
|
||||
group: "Dialog",
|
||||
run: toggle,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -77,65 +91,64 @@ export function DialogUpdate(props: {
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{state().type === "available" || state().type === "installing" || state().type === "failed"
|
||||
? "Update available"
|
||||
: "Update"}
|
||||
Update available
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<Show when={state()} keyed>
|
||||
{(current) => (
|
||||
<Switch>
|
||||
<Match when={current.type === "checking"}>
|
||||
<Spinner shimmer={theme.text.default}>Checking for updates…</Spinner>
|
||||
</Match>
|
||||
<Match when={current.type === "available"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
An update is available. After installing, you'll be prompted to restart OpenCode.
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={current.type === "installing"}>
|
||||
<Spinner shimmer={theme.text.default}>
|
||||
{current.type === "installing" ? `Installing OpenCode ${current.version}…` : ""}
|
||||
</Spinner>
|
||||
</Match>
|
||||
<Match when={current.type === "installed"}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Update successful! A restart is required. Any active sessions will be resumed automatically.
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={current.type === "current"}>
|
||||
<text fg={theme.text.subdued}>OpenCode is already up to date.</text>
|
||||
</Match>
|
||||
<Match when={current.type === "unavailable"}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{current.type === "unavailable" ? current.message : ""}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={current.type === "failed" || current.type === "check-failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
{current.type === "failed" || current.type === "check-failed" ? current.message : ""}
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={state().type === "ready"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
An update is available. Applying will
|
||||
{props.restart
|
||||
? " restart the server and active sessions will be resumed."
|
||||
: " install the update but you will need to manually restart."}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={state().type === "installing"}>
|
||||
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "restarting"}>
|
||||
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>{failure()}</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={buttons().length > 0}>
|
||||
<Show
|
||||
when={state().type === "ready"}
|
||||
fallback={
|
||||
<Show when={state().type === "failed"}>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={close}
|
||||
>
|
||||
<text fg={theme.text.action.primary.focused}>close</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={buttons()}>
|
||||
{(button, index) => (
|
||||
<For each={["skip", "update"] as const}>
|
||||
{(action) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() === index() ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => void button.run()}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "skip") return close()
|
||||
beginInstall()
|
||||
}}
|
||||
>
|
||||
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{button.label}
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -7,33 +7,22 @@ export function DialogVariant() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
value: "default",
|
||||
title: "Default",
|
||||
const options = createMemo(() =>
|
||||
local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(undefined)
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
},
|
||||
...local.model.variant
|
||||
.list()
|
||||
.filter((variant) => variant !== "default")
|
||||
.map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
})),
|
||||
])
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
current={local.model.variant.current()}
|
||||
flat={true}
|
||||
/>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user