mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abcab6643 | ||
|
|
dd71cdbd84 | ||
|
|
1c90e8f01c | ||
|
|
9843d13c29 | ||
|
|
871db0025e | ||
|
|
7b7a67080e | ||
|
|
3361953fe1 | ||
|
|
e9201ce933 | ||
|
|
b38d72e174 | ||
|
|
e1e2efe8ec | ||
|
|
c2b171d7c4 | ||
|
|
c3bf8864b9 | ||
|
|
3693f56374 | ||
|
|
971f4e558e | ||
|
|
dd95e3f2ed | ||
|
|
dfde4628e3 | ||
|
|
e755fce13d | ||
|
|
5b565e8923 | ||
|
|
ec8d95f1f2 | ||
|
|
ea52d18ec5 | ||
|
|
0f9504d6c5 | ||
|
|
f6353a48b5 | ||
|
|
4facc03371 | ||
|
|
485a027c1a | ||
|
|
988a66ca4f | ||
|
|
98c6b41573 | ||
|
|
4ee9bf0213 | ||
|
|
681338c533 | ||
|
|
5464aa4a74 | ||
|
|
36083c9520 | ||
|
|
168aba6183 | ||
|
|
ec19dc09c7 | ||
|
|
fca4701688 | ||
|
|
c720ca20ed | ||
|
|
dae1e8abbd | ||
|
|
ffe4d1522c | ||
|
|
b1860465cd | ||
|
|
cab11795e5 | ||
|
|
810e79a6d4 | ||
|
|
d81604714f | ||
|
|
d1e828c217 | ||
|
|
437cdc03a9 | ||
|
|
219ecada68 | ||
|
|
9e1fa80fb7 | ||
|
|
278db3023f | ||
|
|
c2067d59af | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d | ||
|
|
db80806651 | ||
|
|
47f66de8dd | ||
|
|
c076066c33 | ||
|
|
5c09d051e5 | ||
|
|
0a6111291e | ||
|
|
609044ef0a | ||
|
|
4f32bfca57 | ||
|
|
90112f52db | ||
|
|
d4303a9ca5 | ||
|
|
735556eab8 | ||
|
|
10cac9ab5d | ||
|
|
2d0ce64111 | ||
|
|
3355c93efd | ||
|
|
469e1c035e | ||
|
|
b2e3569add | ||
|
|
0ac458b3b3 | ||
|
|
b278ef6b82 | ||
|
|
3e3a4ae46b | ||
|
|
fe0d9579a7 | ||
|
|
a2594ddefb | ||
|
|
3555cef1c8 | ||
|
|
0fa881efd0 | ||
|
|
37ff501cc6 | ||
|
|
fa126d68e8 | ||
|
|
a0571a8b14 | ||
|
|
cae475df37 | ||
|
|
19781a4447 | ||
|
|
e32de90d88 | ||
|
|
0ee5ef6c43 | ||
|
|
a72f988c90 | ||
|
|
2a132d6f50 | ||
|
|
23418b3885 | ||
|
|
b4ac938c3d | ||
|
|
4b25739e93 | ||
|
|
2f97e1324f | ||
|
|
0018f08832 | ||
|
|
b52f241294 | ||
|
|
254ace8594 | ||
|
|
657f700f5d | ||
|
|
7e1d28b549 | ||
|
|
040d2ffb48 | ||
|
|
0675975f98 | ||
|
|
130232feb6 | ||
|
|
54f2356166 | ||
|
|
ac0dd75c6a | ||
|
|
430886142b | ||
|
|
5a02ec776e | ||
|
|
c303a11725 | ||
|
|
dfceca8de7 | ||
|
|
f04c3fd82b | ||
|
|
1685e70c4b | ||
|
|
73e7b7bc79 | ||
|
|
a5b3802ca3 | ||
|
|
f28d1b44e3 | ||
|
|
b81be469c8 | ||
|
|
fca4a8fa3b | ||
|
|
8520617ca8 | ||
|
|
ab3566ab82 | ||
|
|
b642c1d9ea | ||
|
|
c8cc984aa0 | ||
|
|
5a9448acbb | ||
|
|
f6604cd367 | ||
|
|
acfacede2d | ||
|
|
4a27842fe6 | ||
|
|
d797722187 | ||
|
|
7390832f13 | ||
|
|
7689c3654e | ||
|
|
7df0935ada | ||
|
|
bcd43760df | ||
|
|
9073c522ef | ||
|
|
04c296310e | ||
|
|
79d657b8fe | ||
|
|
606ec4fa38 | ||
|
|
2cdd938152 | ||
|
|
4d73b40e59 | ||
|
|
276f2eef3e | ||
|
|
8806d34ed4 | ||
|
|
cda2bc5100 | ||
|
|
ba753b383f | ||
|
|
eb052f70b4 | ||
|
|
5eaed68c2b | ||
|
|
7d7962b2fa | ||
|
|
0537fd47c7 | ||
|
|
9add9384be | ||
|
|
005522786f | ||
|
|
5379a2dfe0 | ||
|
|
6ee156e0b9 | ||
|
|
7c3c50bac2 | ||
|
|
194a006932 | ||
|
|
3c1f87d2d4 | ||
|
|
c57733a647 | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 | ||
|
|
fc49b16584 | ||
|
|
9b091ebd37 | ||
|
|
12201ee507 | ||
|
|
73bbeb7ce1 | ||
|
|
370b9d484a | ||
|
|
796c423df5 | ||
|
|
978bc53eba | ||
|
|
f7444aba27 | ||
|
|
c987590c5f | ||
|
|
1a3bd3458c | ||
|
|
67a6d52015 | ||
|
|
b1f6d21ef3 | ||
|
|
fa3721fad6 | ||
|
|
02b12aa60e | ||
|
|
1b6a07dda3 | ||
|
|
547d60183d | ||
|
|
12e53ee6d4 | ||
|
|
b629b458f7 | ||
|
|
24dff6929d | ||
|
|
7e2ca5fe26 | ||
|
|
aaed6eaa45 | ||
|
|
01443d5e00 | ||
|
|
70fe186df4 | ||
|
|
fc731d6013 | ||
|
|
4cd6093e31 | ||
|
|
b693c222da | ||
|
|
e31ad14c1d | ||
|
|
6c9115eb75 | ||
|
|
d9dfc5c1d6 | ||
|
|
c921067eb8 | ||
|
|
b76e56eca3 | ||
|
|
3c3ef00b32 | ||
|
|
d54fc305db | ||
|
|
a4aaf18855 | ||
|
|
d0a902815d | ||
|
|
27aaa9ce0e | ||
|
|
0b15abbf9a | ||
|
|
0f22b86670 |
@@ -184,6 +184,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 native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+85
-85
@@ -29,15 +29,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
## Progress
|
||||
|
||||
- [x] Group 1: Foundation and placement (4)
|
||||
- [ ] Group 2: Configuration and capability catalogs (16)
|
||||
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [ ] Group 4: Session lifecycle (12)
|
||||
- [ ] Group 5: Session execution and inputs (11)
|
||||
- [ ] Group 6: Session history and recovery (13)
|
||||
- [ ] Group 7: Inbox, permissions, and forms (19)
|
||||
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
|
||||
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
|
||||
- [ ] Group 10: Events, RPC, and experimental operations (6)
|
||||
- [x] Group 2: Configuration and capability catalogs (16)
|
||||
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [x] Group 4: Session lifecycle (12)
|
||||
- [x] Group 5: Session execution and inputs (11)
|
||||
- [x] Group 6: Session history and recovery (13)
|
||||
- [x] Group 7: Inbox, permissions, and forms (19)
|
||||
- [x] Group 8: Filesystem, worktrees, and VCS (12)
|
||||
- [x] Group 9: PTYs, persistent terminals, and shells (24)
|
||||
- [x] Group 10: Events, RPC, and experimental operations (6)
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
### [x] `GET /api/health` and `GET /api/server`
|
||||
|
||||
- **Decision:** Merge and rename
|
||||
- **Replacement:** `GET /api/status` with operation ID `server.status`.
|
||||
- **Replacement:** `GET /api/info` with operation ID `server.info`.
|
||||
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001â002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 001â002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
|
||||
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
|
||||
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
|
||||
@@ -100,7 +100,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
|
||||
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
|
||||
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
|
||||
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
|
||||
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
|
||||
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
|
||||
|
||||
## Group 3: Credentials, integrations, MCP, and web search
|
||||
@@ -111,21 +111,21 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
|
||||
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
|
||||
| [x] | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
|
||||
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
|
||||
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
|
||||
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
|
||||
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
|
||||
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
|
||||
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
|
||||
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
|
||||
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
|
||||
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
|
||||
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
|
||||
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
|
||||
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
|
||||
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
|
||||
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
|
||||
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
|
||||
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
|
||||
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
|
||||
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
|
||||
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
|
||||
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
|
||||
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
|
||||
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
|
||||
@@ -147,7 +147,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
|
||||
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
|
||||
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
|
||||
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.rename` | Change | Title-only rename now uses the session resource path. |
|
||||
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
|
||||
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
|
||||
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
|
||||
|
||||
@@ -166,7 +166,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
|
||||
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
|
||||
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
|
||||
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside the stable API. |
|
||||
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
|
||||
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
|
||||
|
||||
## Group 6: Session history and recovery
|
||||
@@ -185,7 +185,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
|
||||
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
|
||||
|
||||
@@ -196,24 +196,24 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
|
||||
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
|
||||
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
|
||||
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
|
||||
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
|
||||
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
|
||||
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
|
||||
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
|
||||
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
|
||||
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
|
||||
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
|
||||
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
|
||||
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
|
||||
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
|
||||
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
|
||||
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
|
||||
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
|
||||
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
|
||||
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
|
||||
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
|
||||
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
|
||||
| [x] 086 | â | â | â | Remove | Replaced by `session.inbox.update`. |
|
||||
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
|
||||
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
|
||||
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
|
||||
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
|
||||
| [x] 091 | â | â | â | Remove | State is included by `session.form.get`. |
|
||||
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
|
||||
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
|
||||
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
|
||||
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
|
||||
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
|
||||
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
|
||||
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
|
||||
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
|
||||
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
|
||||
| [x] 101 | â | â | â | Remove | Permission rules are updated through `session.update`. |
|
||||
|
||||
## Group 8: Filesystem, worktrees, and VCS
|
||||
|
||||
@@ -221,18 +221,18 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
|
||||
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
|
||||
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
|
||||
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
|
||||
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
|
||||
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
|
||||
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
|
||||
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
|
||||
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
|
||||
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
|
||||
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
|
||||
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
|
||||
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
|
||||
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
|
||||
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
|
||||
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
|
||||
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
|
||||
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
|
||||
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
|
||||
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
|
||||
|
||||
## Group 9: PTYs, persistent terminals, and shells
|
||||
|
||||
@@ -240,30 +240,30 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
|
||||
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
|
||||
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
|
||||
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
|
||||
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
|
||||
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
|
||||
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
|
||||
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
|
||||
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
|
||||
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
|
||||
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
|
||||
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
|
||||
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
|
||||
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
|
||||
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
|
||||
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
|
||||
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
|
||||
| [x] 136 | â | â | â | Remove | Timeout mutation remains an internal Core shell operation. |
|
||||
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
|
||||
|
||||
## Group 10: Events, RPC, and experimental operations
|
||||
|
||||
@@ -271,9 +271,9 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
|
||||
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
|
||||
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
|
||||
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
|
||||
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
|
||||
| [x] | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
|
||||
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
|
||||
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
|
||||
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
|
||||
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
|
||||
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -87,13 +87,11 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -101,7 +99,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -114,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -178,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -204,7 +201,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +215,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +251,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +278,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +295,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +319,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +339,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -354,6 +351,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
@@ -409,14 +407,12 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -456,12 +452,11 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +528,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +541,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +555,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +570,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +609,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +639,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +654,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +678,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +699,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +721,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +756,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +776,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +810,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +829,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +875,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +889,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +924,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +959,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +992,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1033,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3344,8 +3339,6 @@
|
||||
|
||||
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="],
|
||||
|
||||
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
|
||||
@@ -3640,8 +3633,6 @@
|
||||
|
||||
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
|
||||
|
||||
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
|
||||
@@ -3780,8 +3771,6 @@
|
||||
|
||||
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
|
||||
|
||||
"debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
@@ -3908,16 +3897,12 @@
|
||||
|
||||
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
|
||||
|
||||
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.411", "", {}, "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg=="],
|
||||
|
||||
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
||||
|
||||
"electron-vite": ["electron-vite@6.0.0-beta.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^7.0.0", "esbuild": "^0.25.11", "magic-string": "^0.30.21", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-jltST77AwNxIeTTDtYhnIEA8ZM0RW9jmSJcqS8x/dQcgeeLlxlcJoYNNJ1tv7/Swor0AbXMYteBGdezoAOt+Nw=="],
|
||||
|
||||
"electron-window-state": ["electron-window-state@5.0.3", "", { "dependencies": { "jsonfile": "^4.0.0", "mkdirp": "^0.5.1" } }, "sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg=="],
|
||||
|
||||
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
||||
|
||||
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
|
||||
@@ -4462,8 +4447,6 @@
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
@@ -4474,7 +4457,7 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
|
||||
|
||||
@@ -4736,8 +4719,6 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
@@ -5492,8 +5473,6 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
@@ -5618,8 +5597,6 @@
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
|
||||
|
||||
"ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
|
||||
@@ -6446,10 +6423,6 @@
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
|
||||
@@ -6490,8 +6463,6 @@
|
||||
|
||||
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
|
||||
"electron-updater/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -6522,8 +6493,6 @@
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
||||
@@ -6926,18 +6895,12 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
"@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
|
||||
@@ -7014,8 +6977,6 @@
|
||||
|
||||
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
|
||||
@@ -7394,8 +7355,6 @@
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"conf/dot-prop/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
@@ -7420,6 +7379,8 @@
|
||||
|
||||
"electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
@@ -7568,8 +7529,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
@@ -7578,8 +7537,6 @@
|
||||
|
||||
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||
|
||||
"workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||
|
||||
@@ -168,7 +168,7 @@ else
|
||||
|
||||
package_scope="@opencode"
|
||||
if [ -z "$requested_version" ]; then
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
|
||||
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
||||
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
|
||||
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
|
||||
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
|
||||
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
|
||||
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
|
||||
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
|
||||
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
|
||||
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
|
||||
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
thinkingBudget: Schema.optional(Schema.Int),
|
||||
preserveThinking: Schema.optional(Schema.Boolean),
|
||||
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
toolStream: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
repetitionPenalty: Schema.optional(Schema.Number),
|
||||
responseFormat: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
store: OpenResponsesOptions.Options.fields.store,
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
previousResponseId: Schema.optional(Schema.String),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = {
|
||||
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
|
||||
}
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({
|
||||
readonly type: "enabled"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
} & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
readonly service_tier?: "auto" | "standard_only"
|
||||
readonly serviceTier?: "auto" | "standard_only"
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
|
||||
export type ThinkingInput = typeof Thinking.Encoded
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(knownString<"summarized" | "omitted">()),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
@@ -360,6 +311,53 @@ const AnthropicContainer = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
|
||||
// keys are disjoint per variant so the input type requires exactly one and the
|
||||
// transform can narrow on it. Decoding straight to the wire block keeps the
|
||||
// alias out of the rest of the file.
|
||||
const ThinkingEnabledInput = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
]).pipe(
|
||||
Schema.decodeTo(AnthropicThinkingEnabled, {
|
||||
decode: SchemaGetter.transform((input) => ({
|
||||
type: "enabled" as const,
|
||||
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
|
||||
display: input.display,
|
||||
block_binding: input.block_binding,
|
||||
})),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
|
||||
const OutputConfigInput = Schema.Struct({
|
||||
effort: optionalNull(Schema.String),
|
||||
format: optionalNull(AnthropicJsonOutputFormat),
|
||||
})
|
||||
|
||||
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
|
||||
const Options = Schema.Struct({
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
contextManagement: Schema.optional(ContextManagement),
|
||||
thinking: Schema.optional(Thinking),
|
||||
effort: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
serviceTier: Schema.optional(AnthropicServiceTier),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
container: Schema.optional(AnthropicContainer),
|
||||
inference_geo: optionalNull(Schema.String),
|
||||
inferenceGeo: optionalNull(Schema.String),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
cacheControl: Schema.optional(AnthropicCacheControl),
|
||||
output_config: Schema.optional(OutputConfigInput),
|
||||
outputConfig: Schema.optional(OutputConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -391,7 +389,7 @@ const AnthropicBodyFields = {
|
||||
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
|
||||
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -1001,64 +999,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
(input as Record<string, unknown> | undefined)?.inferenceGeo
|
||||
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
|
||||
const rawCacheControl =
|
||||
(input as Record<string, unknown> | undefined)?.cache_control ??
|
||||
(input as Record<string, unknown> | undefined)?.cacheControl
|
||||
const cache_control =
|
||||
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
|
||||
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
|
||||
: undefined
|
||||
const rawOutputConfig =
|
||||
(input as Record<string, unknown> | undefined)?.output_config ??
|
||||
(input as Record<string, unknown> | undefined)?.outputConfig
|
||||
const outputConfigEffort =
|
||||
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
|
||||
? ((input as Record<string, unknown>).effort as string)
|
||||
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
|
||||
? (rawOutputConfig.effort as string)
|
||||
: undefined
|
||||
const outputConfigFormat =
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
format: outputConfigFormat,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
@@ -1097,35 +1037,12 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
|
||||
const block_binding = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
|
||||
)(input.block_binding)
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools â system â
|
||||
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
// =============================================================================
|
||||
@@ -161,17 +135,50 @@ const GeminiToolConfig = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
thinkingLevel: Schema.optional(GeminiThinkingLevel),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
category: knownString<
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
>(),
|
||||
threshold: knownString<
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
>(),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
|
||||
// object that omits `includeThoughts` asks for thoughts.
|
||||
const GeminiThinkingConfigInput = Schema.Struct({
|
||||
thinkingBudget: lenient(Schema.Number),
|
||||
includeThoughts: lenient(Schema.Boolean),
|
||||
thinkingLevel: lenient(GeminiThinkingLevel),
|
||||
})
|
||||
const Options = Schema.Struct({
|
||||
cachedContent: lenient(Schema.String),
|
||||
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
|
||||
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
|
||||
thinkingConfig: lenient(GeminiThinkingConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
maxOutputTokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
@@ -431,44 +438,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions
|
||||
const value = input?.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = resolveOptions(request)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -168,6 +168,10 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
if (input.webSocket && !channel)
|
||||
yield* Effect.logWarning(`${options.name} does not offer WebSocket for this endpoint; using HTTP`, {
|
||||
url: parts.url,
|
||||
})
|
||||
return {
|
||||
http: {
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
|
||||
@@ -336,12 +336,18 @@ export type OutputItem = StreamItem & { readonly id: string }
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
const OpenResponsesErrorObject = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
|
||||
Schema.decodeTo(OpenResponsesErrorObject, {
|
||||
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
|
||||
@@ -715,7 +715,10 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
|
||||
|
||||
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
// Default off: strict providers 400 on unknown body fields, so only send
|
||||
// the key where compatibility explicitly allows it. Header-based affinity
|
||||
// (x-session-affinity, x-grok-conv-id, ...) is unaffected.
|
||||
const cacheKey = (request.model.compatibility?.supportsPromptCacheKey ?? false) ? ProviderShared.promptCacheKey(request) : undefined
|
||||
return {
|
||||
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
|
||||
// For providers that support `store`, ensure stateless `store:false` is sent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
@@ -29,6 +29,16 @@ const isJson = Schema.is(Schema.Json)
|
||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
|
||||
export const lenient = <const S extends Schema.Top>(schema: S) =>
|
||||
Schema.optionalKey(
|
||||
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
|
||||
)
|
||||
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
|
||||
export const knownString = <Known extends string>() =>
|
||||
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
|
||||
expected: "string",
|
||||
})
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
@@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
|
||||
export const Options = Schema.Struct({
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
store: lenient(Schema.Boolean),
|
||||
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: lenient(Schema.String),
|
||||
streamOptions: lenient(StreamOptions),
|
||||
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: lenient(ReasoningEffort),
|
||||
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: lenient(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: lenient(TextVerbositySchema),
|
||||
serviceTier: lenient(ServiceTierSchema),
|
||||
truncation: lenient(TruncationSchema),
|
||||
allowedTools: lenient(AllowedTools),
|
||||
maxToolCalls: lenient(Schema.Int),
|
||||
parallelToolCalls: lenient(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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"
|
||||
@@ -61,11 +60,6 @@ const responsesRoute = OpenAIResponses.route.with({
|
||||
url.searchParams.delete("api-version")
|
||||
return url.toString()
|
||||
},
|
||||
headers: (headers) => {
|
||||
const apiKey = headers["api-key"]
|
||||
if (!apiKey) return headers
|
||||
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -135,7 +129,7 @@ export const configure = (input: Config) => {
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredRoute(chatRoute, input, modelID)
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } })
|
||||
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -44,7 +44,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false, supportsPromptCacheKey: true },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false, supportsPromptCacheKey: true },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredChat.model<OpenResponsesProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false },
|
||||
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false, supportsPromptCacheKey: true },
|
||||
})
|
||||
const messages = (modelID: string | ModelID) =>
|
||||
configuredMessages.model<MessagesOptionsInput>({
|
||||
|
||||
@@ -112,6 +112,7 @@ export const configure = (input: Config = {}) => {
|
||||
maxTokensField: "max_tokens",
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
supportsPromptCacheKey: true,
|
||||
toolSchema: "moonshot",
|
||||
reasoningField: "reasoning_content",
|
||||
},
|
||||
|
||||
@@ -91,7 +91,10 @@ export const configure = (input: Config = {}) => {
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
|
||||
id,
|
||||
compatibility: { supportsPromptCacheKey: true },
|
||||
})
|
||||
const image = (modelID: string | ModelID) =>
|
||||
OpenAIImages.model({
|
||||
id: modelID,
|
||||
|
||||
@@ -183,7 +183,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
|
||||
model: (modelID: string | ModelID) =>
|
||||
route.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,9 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
|
||||
// key unless this is set; session-affinity headers still flow regardless.
|
||||
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
|
||||
zaiToolStream: Schema.optional(Schema.Boolean),
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
it.effect("does not let server codes override a 4xx rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(Array(5).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies 402 as exhausted quota", () => {
|
||||
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
|
||||
})
|
||||
|
||||
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
|
||||
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
|
||||
const substituted = (message: string) => ({
|
||||
error: { type: "server_error", message: `Upstream request failed: ${message}` },
|
||||
})
|
||||
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
|
||||
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
|
||||
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
|
||||
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
|
||||
[402, substituted("Insufficient account funds")],
|
||||
[402, substituted("Account invoice is overdue")],
|
||||
[429, substituted("Account budget exceeded")],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([status, body]) =>
|
||||
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
|
||||
}
|
||||
expect(
|
||||
[openai, anthropic].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "InvalidRequest"])
|
||||
// Without a contradicting status the same codes still mark provider trouble.
|
||||
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
|
||||
"ProviderInternal",
|
||||
)
|
||||
expect(
|
||||
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -4,6 +4,22 @@ import { Anthropic } from "../../src/providers.js"
|
||||
const model = Anthropic.provider.model("claude-sonnet-4-5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
serviceTier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
},
|
||||
})
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
|
||||
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
|
||||
@@ -166,7 +166,89 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("budgetTokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
|
||||
Effect.gen(function* () {
|
||||
const snake = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const camel = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
serviceTier: "standard_only",
|
||||
container: "container_2",
|
||||
inferenceGeo: "eu",
|
||||
cacheControl: { type: "ephemeral" },
|
||||
outputConfig: { effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(snake.body).toMatchObject({
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
})
|
||||
expect(camel.body).toMatchObject({
|
||||
service_tier: "standard_only",
|
||||
container: "container_2",
|
||||
inference_geo: "eu",
|
||||
cache_control: { type: "ephemeral" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards unknown values for pass-through string enums", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
service_tier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
|
||||
const malformed = [
|
||||
{ service_tier: 1 },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
|
||||
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("unknownOption")
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
|
||||
import { WebSocketTransport } from "../../src/route.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
const message = "gRPC error: Response with id=resp_missing not found"
|
||||
for (const error of [{ type: "api_error", message }, message]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
|
||||
type: "error",
|
||||
error: typeof error === "string" ? { message } : error,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
|
||||
type: "error",
|
||||
error: { message: "Gateway failed" },
|
||||
})
|
||||
expect(
|
||||
yield* decode(
|
||||
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
|
||||
),
|
||||
).toEqual({
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { message: "Gateway failed" } },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
|
||||
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
|
||||
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
|
||||
]) {
|
||||
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
|
||||
})
|
||||
for (const options of [{ webSocket }, {}]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("UnknownProvider")
|
||||
expect(error.message).toBe("Gateway rejected the request")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
|
||||
@@ -248,7 +248,38 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key", () =>
|
||||
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(true)
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.compatible.test/v1" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "compatible-model", compatibility: { supportsPromptCacheKey: true } }),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session_123",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt_cache_key).toBe("session_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the prompt cache key without the compatibility flag", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -261,7 +292,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt_cache_key).toBe("session_123")
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -280,7 +311,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
|
||||
it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
|
||||
@@ -294,7 +325,8 @@ describe("OpenAI Chat route", () => {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
|
||||
const body = decodeJson(yield* Effect.promise(() => web.text()))
|
||||
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
|
||||
// Chat uses the header; prompt_cache_key is Responses-only.
|
||||
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBeUndefined()
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Logger, Ref, Schema, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
@@ -760,7 +760,11 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
const next = continuationDriver({
|
||||
...request,
|
||||
input: [...firstInput, reasoning, { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }],
|
||||
input: [
|
||||
...firstInput,
|
||||
reasoning,
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
@@ -795,7 +799,10 @@ describe("OpenAI Responses route", () => {
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const appended = [...request.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }]
|
||||
const appended = [
|
||||
...request.input,
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
]
|
||||
const changes = [
|
||||
{ ...request, model: "gpt-5.3", input: appended },
|
||||
{ ...request, instructions: "Changed", input: appended },
|
||||
@@ -804,7 +811,10 @@ describe("OpenAI Responses route", () => {
|
||||
{ ...request, metadata: { source: "two" }, input: appended },
|
||||
{
|
||||
...request,
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] },
|
||||
appended[1],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -900,7 +910,10 @@ describe("OpenAI Responses route", () => {
|
||||
const second = continuationDriver(
|
||||
{
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
],
|
||||
},
|
||||
classifyingChannelDriver,
|
||||
)
|
||||
@@ -951,7 +964,10 @@ describe("OpenAI Responses route", () => {
|
||||
}
|
||||
const secondRequest = {
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
],
|
||||
}
|
||||
const saved = checkpoint(
|
||||
yield* continuationDriver(firstRequest).observe(
|
||||
@@ -1082,7 +1098,9 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
const expected = {
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] },
|
||||
],
|
||||
metadata: { source: "overlay\uFFFD" },
|
||||
}
|
||||
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
|
||||
@@ -1124,7 +1142,9 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
|
||||
// Azure's WebSocket upgrade accepts the same credential header as HTTP: `api-key` for keys,
|
||||
// `Authorization: Bearer` for Entra tokens. Rewriting a key into a bearer is rejected.
|
||||
it.effect("builds Azure WebSocket requests with v1 URLs and the route's auth header", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
@@ -1133,13 +1153,13 @@ describe("OpenAI Responses route", () => {
|
||||
const cases = [
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
|
||||
authorization: "Bearer azure-key",
|
||||
headers: { "api-key": "azure-key", authorization: undefined },
|
||||
},
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
|
||||
"deployment",
|
||||
),
|
||||
authorization: "Bearer entra-token",
|
||||
headers: { "api-key": undefined, authorization: "Bearer entra-token" },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1150,8 +1170,8 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.gen(function* () {
|
||||
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
|
||||
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
|
||||
expect(exchange.connect.headers.authorization).toBe(item.authorization)
|
||||
expect(exchange.connect.headers["api-key"]).toBeUndefined()
|
||||
expect(exchange.connect.headers.authorization).toBe(item.headers.authorization)
|
||||
expect(exchange.connect.headers["api-key"]).toBe(item.headers["api-key"])
|
||||
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||
type: "response.create",
|
||||
@@ -1199,6 +1219,11 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const warnings: string[] = []
|
||||
const logger = Logger.make((entry) => {
|
||||
if (entry.logLevel !== "Warn") return
|
||||
warnings.push(String(Array.isArray(entry.message) ? entry.message[0] : entry.message))
|
||||
})
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
|
||||
@@ -1214,6 +1239,9 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
expect(warnings).toEqual(
|
||||
cases.map(() => "Azure OpenAI Responses does not offer WebSocket for this endpoint; using HTTP"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1842,7 +1870,11 @@ describe("OpenAI Responses route", () => {
|
||||
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }],
|
||||
},
|
||||
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
|
||||
@@ -1913,6 +1945,30 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops a malformed provider option without discarding its siblings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
topLogprobs: 25,
|
||||
metadata: { tenant: 7 },
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
maxToolCalls: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.top_logprobs).toBeUndefined()
|
||||
expect(prepared.body.metadata).toBeUndefined()
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high" })
|
||||
expect(prepared.body.service_tier).toBe("priority")
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -4599,8 +4655,20 @@ describe("OpenAI Responses route", () => {
|
||||
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
|
||||
)
|
||||
expect(prepared.map((request) => request.body.input)).toEqual([
|
||||
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
|
||||
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
|
||||
[
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
|
||||
},
|
||||
],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -42,3 +42,18 @@ story("hides and restores the same registration for Review tabs and unmount", as
|
||||
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("hides the native view immediately while the pane stays mounted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const toggle = root.getByRole("button", { name: "Toggle Review tab", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
// Read in the same task as the click so a deferred animation-frame hide cannot pass.
|
||||
const visible = await toggle.evaluate((element) => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true }))
|
||||
return document.querySelector('[data-testid="native-Alpha"]')?.getAttribute("data-visible")
|
||||
})
|
||||
expect(visible).toBe("false")
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(1)
|
||||
await toggle.click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
@@ -9,12 +9,17 @@ for (const theme of ["light", "dark"]) {
|
||||
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
|
||||
const shimmer = logo.locator(".wordmark-shimmer")
|
||||
const base = logo.locator("svg").first()
|
||||
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(base).toHaveCSS("opacity", "0.16")
|
||||
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
|
||||
await expect(logo.locator("g[mask]")).toHaveCount(0)
|
||||
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
|
||||
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
|
||||
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
|
||||
await expect(base.locator("path").nth(1)).toHaveAttribute("fill", "var(--icon-base)")
|
||||
await expect(base.locator("path").nth(9)).toHaveCSS(
|
||||
"fill",
|
||||
await base.locator("path").nth(1).evaluate((element) => getComputedStyle(element).fill),
|
||||
)
|
||||
await expect(base.locator('path[fill="var(--icon-weak-base)"]')).toHaveCount(8)
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => {
|
||||
@@ -77,7 +82,7 @@ for (const theme of ["light", "dark"]) {
|
||||
await page.setViewportSize({ width: 360, height: 640 })
|
||||
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
|
||||
const logo = component.locator('[data-component="new-session-wordmark"]')
|
||||
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
await expect(logo.locator("svg").first()).toHaveCSS("opacity", "0.16")
|
||||
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
|
||||
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
|
||||
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
|
||||
|
||||
@@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
started: message.time.created,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
|
||||
if (url.pathname === "/api/info")
|
||||
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
|
||||
@@ -108,6 +108,15 @@ test("non-Git folders show their status without offering worktree actions", asyn
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create", { currentDirectory: workspace })
|
||||
await page.getByRole("button", { name: "New worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run locally")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.calls.find((call) => call.type === "session")?.directory).toBe(directory)
|
||||
})
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
@@ -296,7 +305,12 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
worktree = "main",
|
||||
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
|
||||
) {
|
||||
const currentDirectory = options.currentDirectory ?? directory
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
@@ -315,7 +329,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
directory: currentDirectory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
@@ -442,7 +456,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
@@ -455,12 +469,12 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
if (options.direction) await openWithDirection(page, draftPath, options.direction)
|
||||
if (!options.direction) await page.goto(draftPath)
|
||||
|
||||
@@ -22,7 +22,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsBranches: ["feature/api", "main", "origin/release"],
|
||||
})
|
||||
await page.route("**/api/vcs/branches?*", (route) => {
|
||||
await page.route("**/api/vcs/branch?*", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
|
||||
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
|
||||
})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const password = "pairing-secret"
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const requests: { origin: string; authorization: string | undefined }[] = []
|
||||
await page.addInitScript((origin) => {
|
||||
if (localStorage.getItem("opencode.global.dat:server")) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
|
||||
)
|
||||
}, origin)
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push({
|
||||
origin: new URL(route.request().url()).origin,
|
||||
authorization: route.request().headers().authorization,
|
||||
})
|
||||
// Pairing must succeed even when the API is unavailable.
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
|
||||
})
|
||||
|
||||
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
|
||||
await expect(page).toHaveURL(`${origin}/`)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
|
||||
.toEqual([{ type: "http", http: { url: origin, password } }])
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
|
||||
requests.length = 0
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("the unpaired page loads without starting server requests", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.goto("/connect")
|
||||
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-pending-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-offline-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
// The reconnect sweep must resync active sessions instead of trusting
|
||||
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-synced-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -16,8 +16,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/status") {
|
||||
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
|
||||
if (url.pathname === "/api/info") {
|
||||
return json(route, {
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: [url.origin],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
}
|
||||
return json(route, {})
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
item: { type: "user", payload: row.payload, delivery: row.delivery },
|
||||
})
|
||||
},
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
|
||||
changes.push({ inboxID: input.inboxID, action: input.action })
|
||||
log.push(`${input.action}:${input.inboxID}`)
|
||||
const index = rows.findIndex((row) => row.id === input.inboxID)
|
||||
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
|
||||
return
|
||||
}
|
||||
row.delivery = "steer"
|
||||
row.delivery = input.action
|
||||
emit("session.inbox.delivery.changed", {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.inboxID,
|
||||
delivery: "steer",
|
||||
delivery: input.action,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(thinking).toHaveCount(0)
|
||||
|
||||
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
|
||||
for (const tool of [
|
||||
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
|
||||
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
|
||||
@@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
)
|
||||
|
||||
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model })
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
|
||||
mock.emit("session.text.started", { ...later, ordinal: 0 })
|
||||
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
|
||||
const response = transcript
|
||||
|
||||
@@ -48,7 +48,10 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
|
||||
if (
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
|
||||
)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
@@ -107,7 +110,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||
const request = await reply
|
||||
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||
expect(request.postDataJSON()).toEqual({ decision: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
|
||||
@@ -138,7 +138,14 @@ test("MCP authentication starts before a slow resource catalog finishes", async
|
||||
test("multiple desktop connections show the session's server name", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.route("http://secondary.test/**", (route) =>
|
||||
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
|
||||
route.fulfill({
|
||||
json: {
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://secondary.test"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ for (const custom of [false, true]) {
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(trigger).toBeEnabled()
|
||||
await trigger.hover()
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
|
||||
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/status")
|
||||
const response = await fetch("/api/info")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
for (const count of [7, 8]) {
|
||||
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
await page.route("**/api/project", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fulfill({ json: projectList(8) })
|
||||
})
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
|
||||
await page.reload()
|
||||
await requested
|
||||
const view = ui(page)
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
try {
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await expect(search).toHaveCount(0)
|
||||
} finally {
|
||||
inventory.resolve()
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
|
||||
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
|
||||
@@ -278,7 +278,11 @@ async function installMotionProbe(page: Page) {
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
if (
|
||||
panelGap &&
|
||||
reviewRegion.getBoundingClientRect().height > 1 &&
|
||||
terminalRegion.getBoundingClientRect().height > 1
|
||||
)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
|
||||
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
|
||||
response.setHeader("cache-control", "no-store")
|
||||
if (path === "/observer.html")
|
||||
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
|
||||
if (path === "/api/status")
|
||||
if (path === "/api/info")
|
||||
return void response
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
|
||||
if (path === "/sw.js" && state.legacy && state.version === "old") {
|
||||
// Model the shipped worker's shared precache name and cache-first navigation behavior.
|
||||
const urls = Object.keys(builds.old).filter(
|
||||
@@ -334,8 +334,13 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
|
||||
|
||||
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
|
||||
await install(page, site.url)
|
||||
const api = await page.goto(`${site.url}/api/status`)
|
||||
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
|
||||
const api = await page.goto(`${site.url}/api/info`)
|
||||
expect(await api?.json()).toEqual({
|
||||
version: "test",
|
||||
pid: 1,
|
||||
urls: ["http://localhost"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
expect(api?.fromServiceWorker()).toBe(false)
|
||||
const asset = await page.goto(`${site.url}/_assets/missing.js`)
|
||||
expect(asset?.status()).toBe(404)
|
||||
|
||||
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
|
||||
}) {}
|
||||
|
||||
const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("event", "/api/event", {
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
@@ -112,10 +112,10 @@ const Group = HttpApiGroup.make("mock")
|
||||
)
|
||||
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
|
||||
.add(
|
||||
@@ -173,7 +173,7 @@ const Group = HttpApiGroup.make("mock")
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
|
||||
HttpApiEndpoint.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
|
||||
params: { ...SessionParams, formID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
@@ -218,8 +218,9 @@ const Group = HttpApiGroup.make("mock")
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
|
||||
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
|
||||
params: { ...SessionParams, inboxID: Schema.String },
|
||||
payload: Schema.Struct({ delivery: Schema.Literals(["steer", "queue"]) }),
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface MockServerConfig {
|
||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
inbox?: unknown[] | (() => unknown[])
|
||||
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
|
||||
}
|
||||
|
||||
type MockStreamWindow = Window & {
|
||||
@@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
}),
|
||||
)
|
||||
.handleAll({
|
||||
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
|
||||
info: () =>
|
||||
Effect.succeed({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: config.server ? [config.server] : [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
}),
|
||||
config: () => Effect.succeed(configEntries),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
@@ -454,9 +460,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionInboxSteer: (ctx) =>
|
||||
sessionInboxUpdate: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
|
||||
config.onInboxChange?.({
|
||||
sessionID: ctx.params.sessionID,
|
||||
inboxID: ctx.params.inboxID,
|
||||
action: ctx.payload.delivery,
|
||||
}),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionSwitchAgent: () => noContent,
|
||||
sessionSwitchModel: () => noContent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -46,7 +46,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -88,12 +87,10 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-24
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
|
||||
import { Font } from "@opencode/ui/font"
|
||||
import { ThemeProvider } from "@opencode/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -102,40 +102,34 @@ export function AppInterface(props: {
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
serversPending?: boolean
|
||||
router?: Component<BaseRouterProps>
|
||||
}) {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<BodyTypography />
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
defaultServer={props.defaultServer}
|
||||
canonicalLocalServer={props.canonicalLocalServer}
|
||||
servers={props.servers}
|
||||
pending={props.serversPending}
|
||||
>
|
||||
<SettingsProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
|
||||
@@ -16,6 +16,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="upload-row"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-label"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-name"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-percent"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-track"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="upload-progress"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-slot="upload-progress-bar"] {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--v2-icon-icon-base);
|
||||
transition: width 160ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="upload-row-cancel"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="composer-attachments"] {
|
||||
timeline-scope: --composer-attachments-scroll;
|
||||
|
||||
|
||||
@@ -1,66 +1,12 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
|
||||
const accepted = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"text/*",
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/toml",
|
||||
"application/x-toml",
|
||||
"application/x-yaml",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
".c",
|
||||
".cc",
|
||||
".cjs",
|
||||
".conf",
|
||||
".cpp",
|
||||
".css",
|
||||
".csv",
|
||||
".cts",
|
||||
".env",
|
||||
".go",
|
||||
".gql",
|
||||
".graphql",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".ini",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".log",
|
||||
".md",
|
||||
".mdx",
|
||||
".mjs",
|
||||
".mts",
|
||||
".py",
|
||||
".rb",
|
||||
".rs",
|
||||
".sass",
|
||||
".scss",
|
||||
".sh",
|
||||
".sql",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
]
|
||||
import type { ComposerPrompt } from "../types"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
|
||||
import type { AttachmentDestination } from "./destination"
|
||||
import { uploads } from "./uploads"
|
||||
|
||||
type PromptTarget = {
|
||||
current: () => ComposerPrompt
|
||||
@@ -74,10 +20,11 @@ export type ComposerAttachmentConfig = {
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
) => Promise<void>
|
||||
directory: () => string
|
||||
destination: () => AttachmentDestination
|
||||
isDialogActive: () => boolean
|
||||
warn: () => void
|
||||
duplicate: () => void
|
||||
onError: (error: unknown) => void
|
||||
onUploadError: (error: unknown) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
onDragCancel?: (callback: () => void) => () => void
|
||||
@@ -102,13 +49,23 @@ export function createComposerAttachments(
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
|
||||
}
|
||||
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
|
||||
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
|
||||
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
|
||||
|
||||
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Everything else, including text, reaches the model as a path on the server that its
|
||||
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
|
||||
const add = async (file: File, target = capture(), clipboard = false) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
if (!mime) {
|
||||
if (toast) input.warn()
|
||||
return false
|
||||
}
|
||||
const destination = input.destination()
|
||||
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
|
||||
void stage(file, mime, target, destination)
|
||||
return true
|
||||
}
|
||||
const addInline = async (file: File, mime: string, target: NonNullable<ReturnType<typeof capture>>, clipboard: boolean) => {
|
||||
const blob = input.store ? await input.store(file) : await createBlobReference(file)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
|
||||
@@ -127,24 +84,45 @@ export function createComposerAttachments(
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
const attachment: ComposerAttachment = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
blob,
|
||||
}
|
||||
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
}
|
||||
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||
const found = await files.reduce(async (result, file) => {
|
||||
const addPath = (
|
||||
target: NonNullable<ReturnType<typeof capture>>,
|
||||
attachment: Pick<PathAttachmentPart, "filename" | "mime" | "path">,
|
||||
) => {
|
||||
if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) {
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor())
|
||||
return true
|
||||
}
|
||||
const stage = async (
|
||||
file: File,
|
||||
mime: string,
|
||||
target: NonNullable<ReturnType<typeof capture>>,
|
||||
destination: AttachmentDestination,
|
||||
) => {
|
||||
const id = uuid()
|
||||
setPending("ids", (ids) => [...ids, id])
|
||||
const path = await uploads
|
||||
.track({ id, filename: file.name, mime, size: file.size }, (report, signal) =>
|
||||
destination.upload(file, report, signal),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
input.onUploadError(error)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id)))
|
||||
if (path) addPath(target, { filename: file.name, mime, path })
|
||||
}
|
||||
const addAttachments = async (files: File[], target = capture()) => {
|
||||
return files.reduce(async (result, file) => {
|
||||
const previous = await result
|
||||
return (await add(file, false, target)) || previous
|
||||
return (await add(file, target)) || previous
|
||||
}, Promise.resolve(false))
|
||||
if (!found && files.length > 0 && toast) input.warn()
|
||||
return found
|
||||
}
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
@@ -159,13 +137,13 @@ export function createComposerAttachments(
|
||||
return file ? [file] : []
|
||||
})
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files, true, target)
|
||||
await addAttachments(files, target)
|
||||
return
|
||||
}
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
const file = await input.readClipboardImage()
|
||||
if (file && (await add(file, true, target, true))) return
|
||||
if (file && (await add(file, target, true))) return
|
||||
}
|
||||
if (!plainText) return
|
||||
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
|
||||
@@ -218,20 +196,33 @@ export function createComposerAttachments(
|
||||
addAttachments,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
/** Uploads still in flight for this composer; sending waits for them. */
|
||||
pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)),
|
||||
cancel(id: string) {
|
||||
uploads.items().find((item) => item.id === id)?.cancel()
|
||||
},
|
||||
pick(fallback: () => void) {
|
||||
if (!input.picker) {
|
||||
fallback()
|
||||
return
|
||||
}
|
||||
void input
|
||||
.picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
|
||||
.catch(input.onError)
|
||||
void input.picker({ defaultPath: input.directory(), multiple: true }, (file) => add(file)).catch(input.onError)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
// The server rejects inline attachments above this size, so larger media takes the path route.
|
||||
const MAX_INLINE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
// Mirrors the media the server forwards to the model as message content.
|
||||
function native(mime: string, input: AttachmentDestination["input"]) {
|
||||
if (imageMimes.has(mime)) return input.image
|
||||
if (mime === "application/pdf") return input.pdf
|
||||
return false
|
||||
}
|
||||
|
||||
const imageExtensions = new Map([
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
@@ -249,6 +240,8 @@ const textMimes = new Set([
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
|
||||
// a binary type. Delivery is decided separately: native media inline, everything else by path.
|
||||
async function attachmentMime(file: File) {
|
||||
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
if (imageMimes.has(type) || type === "application/pdf") return type
|
||||
@@ -259,10 +252,11 @@ async function attachmentMime(file: File) {
|
||||
if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
|
||||
return "text/plain"
|
||||
}
|
||||
const binary = type || "application/octet-stream"
|
||||
const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer())
|
||||
if (bytes.some((byte) => byte === 0)) return
|
||||
if (bytes.some((byte) => byte === 0)) return binary
|
||||
const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
|
||||
if (bytes.length > 0 && control / bytes.length > 0.3) return
|
||||
if (bytes.length > 0 && control / bytes.length > 0.3) return binary
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { authTokenFromCredentials } from "@/runtime/server/api"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { ComposerControls } from "../adapter"
|
||||
|
||||
// Where a prompt is headed: the model that reads it and the server that runs its tools.
|
||||
export type AttachmentDestination = {
|
||||
/** Input modalities the selected model reads natively. */
|
||||
input: { image: boolean; pdf: boolean }
|
||||
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
|
||||
local: boolean
|
||||
/** Streams a file into the server's temporary directory and returns its absolute path there. */
|
||||
upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise<string>
|
||||
}
|
||||
|
||||
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
return (): AttachmentDestination => ({
|
||||
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
|
||||
local: server.isLocal,
|
||||
upload: async (file, report, signal) => {
|
||||
const info = await sdk.api.server.info({ signal })
|
||||
// One directory per upload keeps the original filename without collisions; the server
|
||||
// normalizes the separators and returns the resolved path.
|
||||
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
|
||||
url.searchParams.set("location[directory]", location().directory)
|
||||
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
|
||||
return write(url, file, server.conn.http.password, report, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so
|
||||
// the one request that needs both goes through XMLHttpRequest. The browser streams the File
|
||||
// from disk; nothing is buffered in the renderer.
|
||||
function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("POST", url)
|
||||
xhr.responseType = "json"
|
||||
xhr.setRequestHeader("content-type", "application/octet-stream")
|
||||
if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`)
|
||||
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
resolve((xhr.response as { data: { path: string } }).data.path)
|
||||
})
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
|
||||
xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")))
|
||||
signal.addEventListener("abort", () => xhr.abort(), { once: true })
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createEffect, createRoot, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Toast, toaster } from "@opencode/ui/toast"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export type Upload = {
|
||||
id: string
|
||||
filename: string
|
||||
mime: string
|
||||
size: number
|
||||
loaded: number
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
// Uploads outlive the composer that started them, so one process-wide list feeds every chip
|
||||
// and the single progress toast.
|
||||
const [state, setState] = createStore<{ items: Upload[] }>({ items: [] })
|
||||
|
||||
export const uploads = {
|
||||
items: () => state.items,
|
||||
/** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */
|
||||
async track<T>(
|
||||
input: Pick<Upload, "id" | "filename" | "mime" | "size">,
|
||||
work: (report: (loaded: number) => void, signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const controller = new AbortController()
|
||||
setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }])
|
||||
try {
|
||||
return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return undefined
|
||||
throw error
|
||||
} finally {
|
||||
setState("items", (items) => items.filter((item) => item.id !== input.id))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host
|
||||
// lives inside the providers, lends the toast its language instance, and gives the content a
|
||||
// root of its own so progress stays reactive.
|
||||
export function UploadToastHost() {
|
||||
const language = useLanguage()
|
||||
let active: { id: number; dispose: () => void } | undefined
|
||||
const dismiss = () => {
|
||||
if (!active) return
|
||||
toaster.dismiss(active.id)
|
||||
active.dispose()
|
||||
active = undefined
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => state.items.length > 0,
|
||||
(uploading) => {
|
||||
if (!uploading) return dismiss()
|
||||
if (active) return
|
||||
const id = toaster.show(
|
||||
(props) =>
|
||||
createRoot((dispose) => {
|
||||
active = { id: props.toastId, dispose }
|
||||
return <UploadToast toastId={props.toastId} language={language} />
|
||||
}),
|
||||
{ persistent: true, resize: () => state.items.length },
|
||||
)
|
||||
active ??= { id, dispose: () => {} }
|
||||
},
|
||||
),
|
||||
)
|
||||
onCleanup(dismiss)
|
||||
return null
|
||||
}
|
||||
|
||||
function UploadToast(props: { toastId: number; language: ReturnType<typeof useLanguage> }) {
|
||||
const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100))
|
||||
return (
|
||||
<Toast toastId={props.toastId}>
|
||||
<Toast.Content>
|
||||
<For each={state.items}>
|
||||
{(item) => (
|
||||
<div data-component="upload-row">
|
||||
<div data-slot="upload-row-label">
|
||||
<span data-slot="upload-row-name" title={item.filename}>
|
||||
{item.filename}
|
||||
</span>
|
||||
<span data-slot="upload-row-percent">
|
||||
{props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="upload-row-track">
|
||||
<div
|
||||
data-component="upload-progress"
|
||||
role="progressbar"
|
||||
aria-label={item.filename}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent(item)}
|
||||
>
|
||||
<div data-slot="upload-progress-bar" style={{ width: `${percent(item)}%` }} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="upload-row-cancel"
|
||||
aria-label={props.language.t("prompt.toast.uploading.cancel")}
|
||||
onClick={() => item.cancel()}
|
||||
>
|
||||
<Icon name="outline-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Toast.Content>
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,13 @@ export type PromptComment = {
|
||||
origin?: "review" | "file"
|
||||
}
|
||||
|
||||
/** An attachment the model receives as a path on the server rather than inline bytes. */
|
||||
export type PromptAttachmentReference = {
|
||||
name: string
|
||||
mime: string
|
||||
path: string
|
||||
}
|
||||
|
||||
function selection(selection: unknown) {
|
||||
if (!selection || typeof selection !== "object") return undefined
|
||||
const startLine = Number((selection as FileSelection).startLine)
|
||||
@@ -58,8 +65,17 @@ export function readPromptPresentation(value: unknown) {
|
||||
const displayText = (value as { displayText?: unknown }).displayText
|
||||
const comments = (value as { comments?: unknown }).comments
|
||||
if (typeof displayText !== "string" || !Array.isArray(comments)) return
|
||||
const attachments = (value as { attachments?: unknown }).attachments
|
||||
return {
|
||||
displayText,
|
||||
attachments: (Array.isArray(attachments) ? attachments : []).flatMap((item): PromptAttachmentReference[] => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const name = (item as { name?: unknown }).name
|
||||
const mime = (item as { mime?: unknown }).mime
|
||||
const path = (item as { path?: unknown }).path
|
||||
if (typeof name !== "string" || typeof mime !== "string" || typeof path !== "string") return []
|
||||
return [{ name, mime, path }]
|
||||
}),
|
||||
comments: comments.flatMap((item): PromptComment[] => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const path = (item as { path?: unknown }).path
|
||||
@@ -80,6 +96,10 @@ export function readPromptPresentation(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAttachmentReference(input: PromptAttachmentReference) {
|
||||
return `Attached file: \`${input.path}\``
|
||||
}
|
||||
|
||||
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
|
||||
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
|
||||
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
} from "../types"
|
||||
import { promptLength } from "../prompt-parts"
|
||||
import { isAttachment, promptLength } from "../prompt-parts"
|
||||
|
||||
export type ComposerStateStore = [
|
||||
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
|
||||
@@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
setStore()((state) => ({
|
||||
prompt: [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...state.prompt.filter((part) => part.type === "image"),
|
||||
...state.prompt.filter(isAttachment),
|
||||
],
|
||||
cursor: content.length,
|
||||
retry: undefined,
|
||||
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
clearRetry()
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
|
||||
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
|
||||
clearRetry()
|
||||
},
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co
|
||||
let position = 0
|
||||
let inserted = false
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
if (isAttachment(part)) return [part]
|
||||
const start = position
|
||||
position += part.content.length
|
||||
if (inserted) return [part]
|
||||
@@ -121,7 +121,7 @@ function insertMention(
|
||||
}
|
||||
let position = 0
|
||||
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
|
||||
if (part.type === "image") return [part]
|
||||
if (isAttachment(part)) return [part]
|
||||
const partStart = position
|
||||
position += part.content.length
|
||||
if (part.type !== "text" || start < partStart || end > position) return [part]
|
||||
@@ -139,7 +139,7 @@ function insertMention(
|
||||
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
|
||||
let offset = 0
|
||||
return prompt.map((part) => {
|
||||
if (part.type === "image") return part
|
||||
if (isAttachment(part)) return part
|
||||
const next = { ...part, start: offset, end: offset + part.content.length }
|
||||
offset = next.end
|
||||
return next
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -12,6 +13,8 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { AttachmentCard } from "@opencode/session-ui/attachment-card"
|
||||
import { ProgressCircle } from "@opencode/ui/progress-circle"
|
||||
import type { Upload } from "../attachments/uploads"
|
||||
import { CommentCard } from "@opencode/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode/session-ui/message-file"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
@@ -24,6 +27,7 @@ import type {
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
import "./editor.css"
|
||||
|
||||
@@ -102,7 +106,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
ref={props.controller.setFileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*,application/json,application/ld+json,application/toml,application/x-toml,application/x-yaml,application/xml,application/yaml,.c,.cc,.cjs,.conf,.cpp,.css,.csv,.cts,.env,.go,.gql,.graphql,.h,.hh,.hpp,.htm,.html,.ini,.java,.js,.json,.jsx,.log,.md,.mdx,.mjs,.mts,.py,.rb,.rs,.sass,.scss,.sh,.sql,.toml,.ts,.tsx,.txt,.xml,.yaml,.yml,.zsh"
|
||||
class="hidden"
|
||||
onChange={(event) => {
|
||||
const list = event.currentTarget.files
|
||||
@@ -149,11 +152,13 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
uploads={props.controller.uploads()}
|
||||
comments={props.controller.comments()}
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
|
||||
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
|
||||
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
|
||||
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
|
||||
/>
|
||||
@@ -192,9 +197,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onInput={(event) => {
|
||||
const cursor = composerCursor(event.currentTarget)
|
||||
const prompt = parseComposerEditor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
const attachments = props.controller.parts().filter(isAttachment)
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
@@ -349,7 +354,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
const active = document.activeElement === editor
|
||||
editor.replaceChildren(
|
||||
...prompt.flatMap<Node>((part) => {
|
||||
if (part.type === "image") return []
|
||||
if (isAttachment(part)) return []
|
||||
if (part.type === "text") return [document.createTextNode(part.content)]
|
||||
const mention = document.createElement("span")
|
||||
mentionParts.set(mention, part)
|
||||
@@ -476,17 +481,22 @@ function composerCursor(editor: HTMLDivElement) {
|
||||
|
||||
export function ComposerAttachments(props: {
|
||||
attachments: ComposerAttachment[]
|
||||
uploads?: Upload[]
|
||||
comments?: ComposerComment[]
|
||||
activeCommentID?: string
|
||||
removeLabel: string
|
||||
onAttachmentClick?: (attachment: ComposerAttachment) => void
|
||||
onAttachmentRemove: (attachment: ComposerAttachment) => void
|
||||
onUploadCancel?: (upload: Upload) => void
|
||||
onCommentClick?: (comment: ComposerComment) => void
|
||||
onCommentRemove?: (comment: ComposerComment) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
|
||||
return (
|
||||
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<Show
|
||||
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
|
||||
>
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
<div
|
||||
data-slot="composer-attachments-scroll"
|
||||
@@ -523,22 +533,34 @@ export function ComposerAttachments(props: {
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<Tooltip
|
||||
value={attachment.type === "path" ? attachment.path : attachment.filename}
|
||||
placement="top"
|
||||
contentClass="break-all"
|
||||
>
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
|
||||
fallback={
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.blob.url}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
@@ -552,6 +574,28 @@ export function ComposerAttachments(props: {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.uploads ?? []}>
|
||||
{(upload) => (
|
||||
<div class="relative group shrink-0" data-slot="composer-upload">
|
||||
<Tooltip value={upload.filename} placement="top" contentClass="break-all">
|
||||
<AttachmentCard title={upload.filename}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ProgressCircle percentage={percent(upload)} />
|
||||
{i18n.t("ui.promptInput.uploading", { percent: percent(upload) })}
|
||||
</span>
|
||||
</AttachmentCard>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onUploadCancel?.(upload)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={i18n.t("ui.promptInput.cancelUpload")}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
data-slot="composer-attachments-fade-left"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { shouldHandlePasteAsAttachment } from "./interaction"
|
||||
|
||||
test("leaves paste to the browser when web clipboard data is unavailable", () => {
|
||||
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
|
||||
})
|
||||
|
||||
test("uses native image reading only when the clipboard has no text", () => {
|
||||
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
|
||||
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
|
||||
})
|
||||
|
||||
test("handles clipboard files as attachments", () => {
|
||||
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
|
||||
})
|
||||
|
||||
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
|
||||
return { types, items } as unknown as DataTransfer
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createEffect, type Accessor } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
|
||||
import type { Upload } from "../attachments/uploads"
|
||||
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
|
||||
import type { ComposerQueue } from "../adapter"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
@@ -74,7 +75,7 @@ export function createComposerEditor(input: {
|
||||
const draft = createComposerEditorActions(input.store)
|
||||
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
|
||||
function addPart(part: ComposerPersistedState["prompt"][number]) {
|
||||
if (part.type === "image") return false
|
||||
if (isAttachment(part)) return false
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
draft.addMention(part)
|
||||
return true
|
||||
@@ -169,7 +170,7 @@ export function createComposerEditor(input: {
|
||||
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
|
||||
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
|
||||
draft.setPrompt(
|
||||
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
|
||||
draft.state.prompt.filter(isAttachment),
|
||||
0,
|
||||
)
|
||||
}
|
||||
@@ -315,7 +316,13 @@ export function createComposerEditor(input: {
|
||||
return draft.state.context.items.filter((item) => !!item.comment?.trim())
|
||||
},
|
||||
attachments(): ComposerAttachment[] {
|
||||
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
|
||||
return draft.state.prompt.filter(isAttachment)
|
||||
},
|
||||
uploads(): Upload[] {
|
||||
return attachments?.pending() ?? []
|
||||
},
|
||||
cancelUpload(id: string) {
|
||||
attachments?.cancel(id)
|
||||
},
|
||||
toggleContext(id: string) {
|
||||
dispatch({ type: "context.active", id })
|
||||
@@ -336,11 +343,12 @@ export function createComposerEditor(input: {
|
||||
canSubmit() {
|
||||
if (input.view.submit.available?.() === false) return false
|
||||
if (input.view.draftOnly) return false
|
||||
if (attachments?.pending().length) return false
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
}
|
||||
if (persisted.prompt.some((part) => part.type === "image")) return true
|
||||
if (persisted.prompt.some(isAttachment)) return true
|
||||
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
},
|
||||
@@ -369,6 +377,7 @@ export function createComposerEditor(input: {
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.submit.available?.() === false) return
|
||||
if (input.view.draftOnly) return
|
||||
if (attachments?.pending().length) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
},
|
||||
@@ -384,20 +393,20 @@ export function createComposerEditor(input: {
|
||||
},
|
||||
onPaste(event: ClipboardEvent) {
|
||||
const clipboard = event.clipboardData
|
||||
if (
|
||||
attachments &&
|
||||
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
|
||||
) {
|
||||
const text = clipboard?.getData("text/plain")
|
||||
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
|
||||
void attachments.handlePaste(event)
|
||||
return
|
||||
}
|
||||
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
// insertText emits input events per line, repeatedly parsing and saving the draft.
|
||||
// Escaped HTML inserts multiline text once and preserves native selection and undo.
|
||||
const multiline = text.includes("\n")
|
||||
const value = multiline ? text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") : text
|
||||
const normalized = text.replace(/\r\n?/g, "\n")
|
||||
const multiline = normalized.includes("\n")
|
||||
const value = multiline
|
||||
? normalized.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
: normalized
|
||||
if (
|
||||
typeof document.execCommand === "function" &&
|
||||
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
|
||||
@@ -408,13 +417,13 @@ export function createComposerEditor(input: {
|
||||
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
|
||||
const range = selection.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
const node = document.createTextNode(text)
|
||||
const node = document.createTextNode(normalized)
|
||||
range.insertNode(node)
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
|
||||
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
|
||||
},
|
||||
onDragEnter(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
@@ -450,6 +459,12 @@ export function createComposerEditor(input: {
|
||||
|
||||
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
|
||||
|
||||
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
|
||||
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
|
||||
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
|
||||
return readClipboardImage
|
||||
}
|
||||
|
||||
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
if (inHistory) return position === 0 || position === text.length
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -36,6 +36,24 @@ describe("Composer history", () => {
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
|
||||
const image: Prompt = [
|
||||
{ type: "text", content: "look", start: 0, end: 4 },
|
||||
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
]
|
||||
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
|
||||
expect(entries).toHaveLength(2)
|
||||
|
||||
const untouched = removeHistoryEntry(entries, text("never sent"))
|
||||
expect(untouched).toBe(entries)
|
||||
|
||||
const withoutComments = removeHistoryEntry(entries, image)
|
||||
expect(withoutComments).toBe(entries)
|
||||
|
||||
const removed = removeHistoryEntry(entries, image, [comment("c1")])
|
||||
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
|
||||
})
|
||||
|
||||
test("insertion isolates canonical entries from source mutations", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
import { clonePrompt, isAttachment } from "../prompt-parts"
|
||||
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
|
||||
|
||||
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
|
||||
@@ -35,9 +35,9 @@ export function prependHistoryEntry(
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
.trim()
|
||||
const hasImages = prompt.some((part) => part.type === "image")
|
||||
const hasAttachments = prompt.some(isAttachment)
|
||||
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
if (!text && !hasAttachments && !hasComments) return entries
|
||||
|
||||
const entry = {
|
||||
prompt: clonePrompt(prompt),
|
||||
@@ -48,6 +48,18 @@ export function prependHistoryEntry(
|
||||
return [entry, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
|
||||
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
|
||||
export function removeHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
comments: PromptHistoryComment[] = [],
|
||||
) {
|
||||
const entry = { prompt, comments } satisfies PromptHistoryEntry
|
||||
const next = entries.filter((item) => !isPromptEqual(item, entry))
|
||||
return next.length === entries.length ? entries : next
|
||||
}
|
||||
|
||||
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
||||
return (
|
||||
commentA.path === commentB.path &&
|
||||
@@ -86,7 +98,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt
|
||||
if (partA.type === "skill") {
|
||||
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
|
||||
}
|
||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
|
||||
}
|
||||
if (entryA.comments.length !== entryB.comments.length) return false
|
||||
for (let i = 0; i < entryA.comments.length; i++) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
prependHistoryEntry,
|
||||
removeHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
@@ -13,6 +14,7 @@ import { PromptHistoryState } from "../schema"
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = typeof PromptHistoryState.Type
|
||||
@@ -32,6 +34,13 @@ function createComposerHistoryStore(
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
remove(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = removeHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +65,12 @@ export function createComposerHistory() {
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
|
||||
const saved = clonePrompt(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.remove(saved, mode, metadata))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,19 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
import { composerPlaceholder } from "./placeholder"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { useAttachmentDestination } from "./attachments/destination"
|
||||
|
||||
export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
@@ -72,7 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}, [])
|
||||
})
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
prompt.current().filter(isAttachment),
|
||||
)
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
@@ -262,6 +264,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
@@ -319,8 +322,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
void resolveBlobUrl(attachment.blob).then((src) => {
|
||||
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
|
||||
})
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, adapter.controls(), layout, files, comments)
|
||||
@@ -338,13 +345,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
attachments: {
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk().directory,
|
||||
destination: useAttachmentDestination(adapter.controls),
|
||||
isDialogActive: () => !!dialog.active,
|
||||
warn: () =>
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
}),
|
||||
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
||||
onUploadError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("prompt.toast.uploadFailed.title"),
|
||||
description: composerErrorMessage(language, error),
|
||||
}),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Prompt } from "./state"
|
||||
import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state"
|
||||
|
||||
/** Parts that sit beside the text rather than inside it. */
|
||||
export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart {
|
||||
return part.type === "image" || part.type === "path"
|
||||
}
|
||||
|
||||
export function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) =>
|
||||
@@ -17,7 +22,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
|
||||
...clonePrompt(prompt),
|
||||
{ type: "text", content: "\n\n", start, end: offset },
|
||||
...clonePrompt(following).map((part) =>
|
||||
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
|
||||
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>) {
|
||||
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
|
||||
}
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
test("builds text, files, and agents from the prompt", () => {
|
||||
const prompt: Prompt = [
|
||||
@@ -21,9 +25,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
images: [inline("a.png", "image/png")],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -45,16 +47,7 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
|
||||
text: "check these",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -70,14 +63,9 @@ describe("buildPromptRequest", () => {
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
inline("opencode.global.dat", "text/plain", {
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,AAA",
|
||||
},
|
||||
}),
|
||||
],
|
||||
text: "inspect this",
|
||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import {
|
||||
formatAttachmentReference,
|
||||
formatCommentNote,
|
||||
type PromptAttachmentReference,
|
||||
type PromptComment,
|
||||
} from "@/composer/comment-note"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
@@ -12,6 +17,7 @@ type PromptRequest = {
|
||||
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
attachments: PromptAttachmentReference[]
|
||||
}
|
||||
|
||||
type ContextFile = {
|
||||
@@ -56,6 +62,7 @@ const parseCommentMentions = (comment: string) => {
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
|
||||
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
|
||||
|
||||
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
|
||||
@@ -106,18 +113,27 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
return [file, ...mentions]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => ({
|
||||
const inline = input.images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.sourcePath ?? attachment.filename,
|
||||
}))
|
||||
// Like comments, path references reach the model as text and the message UI through metadata.
|
||||
const attachments = input.prompt
|
||||
.filter(isPathAttachment)
|
||||
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
|
||||
|
||||
return {
|
||||
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
|
||||
text: [
|
||||
...(input.text.trim() ? [input.text] : []),
|
||||
...attachments.map(formatAttachmentReference),
|
||||
...comments.map(formatCommentNote),
|
||||
].join("\n"),
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...images],
|
||||
files: [...files, ...context, ...inline],
|
||||
agents,
|
||||
skills,
|
||||
comments,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
|
||||
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
@@ -149,12 +149,16 @@ describe("composer persistence schemas", () => {
|
||||
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
|
||||
],
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt).toHaveLength(6)
|
||||
expect(value.prompt[0]).toEqual({
|
||||
...image,
|
||||
sourcePath: "/image.png",
|
||||
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
|
||||
})
|
||||
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
|
||||
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
|
||||
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
const encoded = Schema.encodeSync(ComposerStore)(value)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(
|
||||
|
||||
@@ -61,11 +61,12 @@ const ImageFields = {
|
||||
}
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
|
||||
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
|
||||
})
|
||||
|
||||
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
|
||||
// inline data remains usable, but unresolved references are not renderable.
|
||||
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
|
||||
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
|
||||
export const ImageAttachmentPart = Schema.Struct({
|
||||
...ImageFields,
|
||||
blob: Persistence.optional(
|
||||
@@ -94,7 +95,24 @@ export const ImageAttachmentPart = Schema.Struct({
|
||||
)
|
||||
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
|
||||
// A file the model receives as a path on the server: its bytes never enter the draft store.
|
||||
export const PathAttachmentPart = Persistence.struct({
|
||||
type: Schema.Literal("path"),
|
||||
id: Schema.String,
|
||||
filename: Schema.String,
|
||||
mime: Schema.String,
|
||||
path: Schema.String,
|
||||
})
|
||||
export type PathAttachmentPart = typeof PathAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
TextPart,
|
||||
FileAttachmentPart,
|
||||
AgentPart,
|
||||
SkillPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
])
|
||||
export type ContentPart = typeof ContentPart.Type
|
||||
export const Prompt = Persistence.array(ContentPart)
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
@@ -88,6 +88,15 @@ describe("prompt state initialization", () => {
|
||||
start: 5,
|
||||
end: 12,
|
||||
},
|
||||
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
|
||||
{
|
||||
type: "image",
|
||||
id: "missing-blob",
|
||||
filename: "missing.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "content-hash-without-a-url", url: "" },
|
||||
},
|
||||
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
SkillPart,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt } from "./prompt-parts"
|
||||
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
|
||||
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createComposerSubmission(input: {
|
||||
if (initial !== target) {
|
||||
initial.reset()
|
||||
// A preparing session may already have an unsent follow-up in its promoted composer.
|
||||
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
|
||||
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
}
|
||||
if (!following) target.reset()
|
||||
|
||||
@@ -53,6 +53,7 @@ function submitInput(
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
@@ -60,7 +61,9 @@ function submitInput(
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
removeFromHistory: (prompt) =>
|
||||
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
@@ -533,12 +536,8 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
)
|
||||
const history: string[] = []
|
||||
const submission = submitInput(adapter, notify, "normal", () => [], history)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -549,6 +548,8 @@ describe("Composer submission", () => {
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: text }])
|
||||
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
|
||||
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
@@ -31,6 +32,7 @@ type ComposerSubmitInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
@@ -58,12 +60,22 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!value) {
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
}
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
|
||||
const value = {
|
||||
...read,
|
||||
images: await Promise.all(
|
||||
read.images.map(async (image) => ({
|
||||
...image,
|
||||
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
|
||||
})),
|
||||
),
|
||||
}
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
@@ -151,6 +163,9 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
})),
|
||||
metadata: {
|
||||
displayText: value.text,
|
||||
attachments: value.prompt.flatMap((part) =>
|
||||
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
|
||||
),
|
||||
comments: value.context.flatMap((item) =>
|
||||
item.comment?.trim()
|
||||
? [
|
||||
@@ -185,7 +200,7 @@ function readSubmission(
|
||||
if (mode === "shell" && !text.trim()) return
|
||||
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
const comments = context.filter((item) => !!item.comment?.trim()).length
|
||||
if (!text.trim() && images.length === 0 && comments === 0) return
|
||||
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
@@ -236,6 +251,8 @@ function restoreSubmission(
|
||||
) {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
// The prompt is back in the composer; its history entry would only keep attachments referenced.
|
||||
input.removeFromHistory(value.prompt, value.mode, comments)
|
||||
restored.target.set(restored.prompt, promptLength(restored.prompt))
|
||||
restored.target.mode.set(value.mode)
|
||||
restored.target.context.replaceComments(
|
||||
@@ -358,6 +375,7 @@ async function sendPrompt(
|
||||
metadata: {
|
||||
displayText: request.displayText,
|
||||
comments: request.comments,
|
||||
attachments: request.attachments,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
@@ -372,19 +390,15 @@ async function sendPrompt(
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
const images = await Promise.all(
|
||||
value.images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
|
||||
)
|
||||
const request = buildPromptRequest({
|
||||
return buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
context: value.context,
|
||||
images,
|
||||
text: value.text,
|
||||
sessionDirectory: session.directory,
|
||||
})
|
||||
return request
|
||||
}
|
||||
|
||||
function failSubmission(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
|
||||
export type ComposerInteractionState = {
|
||||
mode: "normal" | "shell"
|
||||
@@ -236,7 +237,7 @@ function populated(persisted: ComposerPersistedState) {
|
||||
return (
|
||||
!!promptText(persisted).trim() ||
|
||||
persisted.context.items.length > 0 ||
|
||||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
|
||||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
|
||||
import type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
FileAttachmentPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
SkillPart,
|
||||
} from "./state"
|
||||
|
||||
export type ComposerFilePart = FileAttachmentPart
|
||||
export type ComposerAgentPart = AgentPart
|
||||
export type ComposerSkillPart = SkillPart
|
||||
export type ComposerAttachment = ImageAttachmentPart
|
||||
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
|
||||
export type ComposerPrompt = Prompt
|
||||
export type ComposerComment = ComposerStore["context"]["items"][number]
|
||||
export type ComposerPersistedState = ComposerStore
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
|
||||
export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
@@ -15,6 +14,7 @@ export type {
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useGlobal } from "./runtime/server/runtime"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
@@ -146,7 +145,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const next = title.trim()
|
||||
if (!next || next === sessionLabel(session)) return true
|
||||
return ctx.sdk.api.session
|
||||
.rename({ sessionID: session.id, title: next })
|
||||
.update({ sessionID: session.id, title: next })
|
||||
.then(() => {
|
||||
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
|
||||
// Rename advances time.updated server-side; re-sync the canonical
|
||||
@@ -327,19 +326,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.location.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
// Calendar day in the local time zone, comparable as a number.
|
||||
function localDay(date: Date) {
|
||||
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const now = new Date()
|
||||
const today = localDay(now)
|
||||
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
|
||||
const todaySessions = records.filter((record) => day(record) === today)
|
||||
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
|
||||
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
|
||||
@@ -42,8 +42,14 @@
|
||||
container-name: session-chat;
|
||||
}
|
||||
|
||||
[data-component="new-session"] {
|
||||
container-type: inline-size;
|
||||
container-name: new-session;
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div),
|
||||
[data-slot="new-session-content"] {
|
||||
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
|
||||
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -53,7 +59,8 @@
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"][data-summary-resizing="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div),
|
||||
[data-component="new-session"][data-summary-resizing="true"] [data-slot="new-session-content"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -70,6 +77,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Shift only while centered content overlaps the 280px summary cards and their 16px edge inset:
|
||||
880px + 2 * (280px + 16px) = 1472px. Center it in the remaining space, reserving 320px. */
|
||||
@container new-session (min-width: 1200px) and (width < 1472px) {
|
||||
[data-component="new-session"][data-summary-open="true"] [data-slot="new-session-content"] {
|
||||
--session-summary-translate: -160px;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-summary-translate: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
|
||||
[data-slot="session-timeline-scroll"]
|
||||
> .scroll-view__thumb {
|
||||
|
||||
@@ -9,6 +9,10 @@ export function useNewSessionCommands(input: {
|
||||
empty: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
workspace: {
|
||||
enabled: () => boolean
|
||||
cycle: () => void
|
||||
}
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
@@ -40,5 +44,13 @@ export function useNewSessionCommands(input: {
|
||||
disabled: input.project.empty(),
|
||||
onSelect: input.project.open,
|
||||
},
|
||||
{
|
||||
id: "session.location.cycle",
|
||||
title: language.t("command.session.location.cycle"),
|
||||
category: language.t("command.category.workspace"),
|
||||
keybind: "mod+alt+l",
|
||||
disabled: !input.workspace.enabled(),
|
||||
onSelect: input.workspace.cycle,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export function createNewSessionComposerAdapter(props: {
|
||||
submitted: props.submitted,
|
||||
async start(selection, submission, message) {
|
||||
const draftID = props.draftID
|
||||
const projectDirectory = location().directory
|
||||
const currentDirectory = location().directory
|
||||
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LocationProvider } from "@/workspaces/location"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { ComposerPersistenceProvider } from "@/composer/persistence"
|
||||
import { ServerProvider, useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import NewSession from "@/new-session/screen"
|
||||
@@ -30,11 +30,23 @@ export function DraftRoute() {
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
|
||||
return (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<Show when={conn()} keyed>
|
||||
<Show
|
||||
when={conn()}
|
||||
keyed
|
||||
fallback={
|
||||
// The shell is up before the local service has connected; hold the panel's place.
|
||||
<Show when={servers.pending}>
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised />
|
||||
</SessionRouteFrame>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ResolvedDraftContent draft={props.draft} />
|
||||
|
||||
@@ -60,6 +60,10 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
empty: project.empty,
|
||||
open: () => project.setOpen(true),
|
||||
},
|
||||
workspace: {
|
||||
enabled: workspace.bar.visible,
|
||||
cycle: workspace.selection.cycle,
|
||||
},
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!composer.ready()) return
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
|
||||
import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
@@ -48,7 +50,21 @@ export function NewSessionView(props: {
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const [store, setStore] = createStore({ summary: false })
|
||||
const [store, setStore] = createStore({
|
||||
summary: false,
|
||||
content: undefined as HTMLDivElement | undefined,
|
||||
summaryResizeTranslate: undefined as string | undefined,
|
||||
})
|
||||
const finishWindowResize = debounce(() => setStore("summaryResizeTranslate", undefined), 150)
|
||||
onMount(() => {
|
||||
makeEventListener(window, "resize", () => {
|
||||
if (store.summaryResizeTranslate === undefined) {
|
||||
// Freeze the painted offset, including an in-flight slide, until resizing settles.
|
||||
setStore("summaryResizeTranslate", store.content ? getComputedStyle(store.content).translate : "none")
|
||||
}
|
||||
finishWindowResize()
|
||||
})
|
||||
})
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
WorkspaceOnboardingSchema,
|
||||
@@ -63,6 +79,9 @@ export function NewSessionView(props: {
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
data-component="new-session"
|
||||
data-summary-open={store.summary}
|
||||
data-summary-resizing={store.summaryResizeTranslate !== undefined}
|
||||
style={{ "--session-summary-resize-translate": store.summaryResizeTranslate }}
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<ComposerDropzone
|
||||
@@ -89,7 +108,11 @@ export function NewSessionView(props: {
|
||||
</SummaryPopover>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<div
|
||||
ref={(element) => setStore("content", element)}
|
||||
data-slot="new-session-content"
|
||||
class={NEW_SESSION_CONTENT_WIDTH}
|
||||
>
|
||||
<NewSessionWordmark />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
[data-component="new-session-wordmark"] {
|
||||
--icon-strong-base: var(--icon-base);
|
||||
--wordmark-shimmer-opacity: 0.45;
|
||||
|
||||
.wordmark-shimmer {
|
||||
--icon-base: white;
|
||||
--icon-strong-base: white;
|
||||
--icon-weak-base: transparent;
|
||||
|
||||
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
|
||||
color: white;
|
||||
mix-blend-mode: screen;
|
||||
@@ -13,6 +18,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="light"] [data-component="new-session-wordmark"] {
|
||||
--icon-weak-base: color-mix(in srgb, var(--icon-base) 30%, transparent);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
|
||||
/* Preserve the previous dark highlight strength: 0.16 Ã 0.7 Ã 0.6. */
|
||||
--wordmark-shimmer-opacity: 0.0672;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Wordmark } from "@opencode/ui/wordmark"
|
||||
import { Logo } from "@opencode/ui/logo"
|
||||
import "./wordmark.css"
|
||||
|
||||
export function NewSessionWordmark() {
|
||||
@@ -8,9 +8,9 @@ export function NewSessionWordmark() {
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
|
||||
>
|
||||
<div data-slot="wordmark-reveal" class="relative">
|
||||
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
|
||||
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
|
||||
<div data-slot="wordmark-reveal" class="relative mx-auto w-4/5">
|
||||
<Logo class="block aspect-[720/129] w-full opacity-[0.16]" />
|
||||
<Logo class="wordmark-shimmer absolute inset-0 aspect-[720/129] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
|
||||
import {
|
||||
cycleNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionGit,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./controller"
|
||||
|
||||
describe("new session workspace selection", () => {
|
||||
test("uses main when the workspace bar is unavailable", () => {
|
||||
@@ -64,4 +69,16 @@ describe("new session workspace selection", () => {
|
||||
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
|
||||
expect(resolveNewSessionGit({})).toBe(false)
|
||||
})
|
||||
|
||||
test("cycles between local and a new worktree", () => {
|
||||
expect(cycleNewSessionWorktree({ current: "main" })).toBe("create")
|
||||
expect(cycleNewSessionWorktree({ current: "create" })).toBe("main")
|
||||
})
|
||||
|
||||
test("includes the selected existing worktree in the cycle", () => {
|
||||
const existing = "/project/feature"
|
||||
expect(cycleNewSessionWorktree({ current: existing, existing })).toBe("main")
|
||||
expect(cycleNewSessionWorktree({ current: "main", existing })).toBe("create")
|
||||
expect(cycleNewSessionWorktree({ current: "create", existing })).toBe(existing)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,12 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
|
||||
return input.projectVcs === "git" || input.branch !== undefined
|
||||
}
|
||||
|
||||
export function cycleNewSessionWorktree(input: { current: string; existing?: string }) {
|
||||
if (input.current === "main") return "create"
|
||||
if (input.current === "create") return input.existing ?? "main"
|
||||
return "main"
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController(input: {
|
||||
selectedWorktree: () => string | undefined
|
||||
selectedBranch: () => string | undefined
|
||||
@@ -49,7 +55,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const [state, setState] = createStore({ search: "" })
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
existing: undefined as { projectID: string; directory: string } | undefined,
|
||||
})
|
||||
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
@@ -142,7 +151,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
|
||||
({ directory, search }) =>
|
||||
serverSDK.api.vcs
|
||||
.branches({ location: { directory }, search, limit: 50 })
|
||||
.branch.list({ location: { directory }, search, limit: 50 })
|
||||
.then((response) => ({ directory, search, data: response.data }))
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
@@ -159,6 +168,8 @@ export function createNewSessionWorkspaceController(input: {
|
||||
createEffect(() => {
|
||||
const selection = value()
|
||||
if (selection === "main" || selection === "create") return
|
||||
const project = currentProject()
|
||||
if (project) setState("existing", { projectID: project.id, directory: selection })
|
||||
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
|
||||
})
|
||||
const branch = createMemo(() =>
|
||||
@@ -176,6 +187,20 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
|
||||
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
|
||||
}
|
||||
const select = (worktree: string) => {
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(worktree)
|
||||
remember(worktree)
|
||||
}
|
||||
// The remembered worktree may have been removed since it was selected. Cycling to a directory the
|
||||
// inventory no longer contains would resolve back to the fallback and leave the cycle stuck.
|
||||
const existing = () => {
|
||||
const project = currentProject()
|
||||
const previous = state.existing
|
||||
if (!project || previous?.projectID !== project.id) return
|
||||
if (!worktreeDirectories().some((item) => sameDirectory(item, previous.directory))) return
|
||||
return previous.directory
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
@@ -193,11 +218,8 @@ export function createNewSessionWorkspaceController(input: {
|
||||
input.setSelectedBranch(undefined)
|
||||
},
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(worktree)
|
||||
remember(worktree)
|
||||
},
|
||||
set: select,
|
||||
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
|
||||
create: (branch: string) => {
|
||||
input.setSelectedBranch(branch)
|
||||
input.setSelectedWorktree("create")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -9,6 +8,8 @@ export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
const RECENT_LIMIT = 5
|
||||
// luxon's diffNow().as("months") used an average month; keep the same window.
|
||||
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
@@ -29,27 +30,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
),
|
||||
)
|
||||
|
||||
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
|
||||
const release = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
available().map(
|
||||
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
filter((x) => {
|
||||
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
|
||||
return Math.abs(Date.now() - released) < sixMonths
|
||||
}),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -101,9 +98,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
// Models without a parseable release date stay visible.
|
||||
return !Number.isFinite(release().get(key) ?? NaN)
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
|
||||
@@ -191,7 +191,6 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "ClaudeáŖ GPTáŖ Gemini áĨá áááŊáá á¨áᎠá¨á°áĩá°áĢá¨á áá´ááŊ",
|
||||
"dialog.provider.opencode.tagline": "áŗáá á¨á°ááģáš áá´ááŊ",
|
||||
"dialog.provider.opencodeGo.tagline": "áá
á°á ááĒ áááá á°á",
|
||||
"dialog.provider.anthropic.note": "á¨ááĨáŗ á¨Claude áá´ááŊáŖ áᎠáĨá áááĩá á¨ááŽ",
|
||||
"dialog.provider.copilot.note": "AI áá´ááŊ á GitHub Copilot] á áŠá áŽáĩ áááĩá¨á áĨáá",
|
||||
"dialog.provider.openai.note": "GPT áá´ááŊ áááŖá áĨá áŊááŗ ááá á á ááá AI á°ááŖáĢáĩ",
|
||||
"dialog.provider.google.note": "Gemini áááŖááŖ á¨á°ááአááážáŊ áá´ááŊ",
|
||||
@@ -208,7 +207,6 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "á¨70+ á áá á á
áĢáĸááŊá áááá¨áą",
|
||||
"dialog.provider.viewAll": "á°á¨ááĒ á á
áĢáĸááŊá á áŗá",
|
||||
"provider.connect.title": "{{provider}} á ááá",
|
||||
"provider.connect.title.anthropicProMax": "á Claude Pro/Max áááĄ",
|
||||
"provider.connect.selectMethod": "á¨áááĸáĢ áá´á á{{provider}} áá¨áĨáĸ",
|
||||
"provider.connect.method.apiKey": "API ááá",
|
||||
"provider.connect.method.browser": "á áŗáŊ",
|
||||
@@ -371,8 +369,6 @@ export const dict = {
|
||||
"prompt.attachment.remove": "ááŖáĒá á áĩáááĩ",
|
||||
"prompt.action.send": "áá",
|
||||
"prompt.action.stop": "á áá",
|
||||
"prompt.toast.pasteUnsupported.title": "á¨ááá°áá ááŖáĒ",
|
||||
"prompt.toast.pasteUnsupported.description": "ááĩááŊáŖ áá˛á¤ááŊ ááá á¨áŊáá ááááŊ áĨáģ áĨáá
áá ááŖáአááŊáááĸ",
|
||||
"prompt.toast.attachmentDuplicate.title": "áá
ááá á áĩááĩá á°á°á
áá",
|
||||
"prompt.toast.modelAgentRequired.title": "ááĒá áĨá áá´á ááá¨áĄ",
|
||||
"prompt.toast.modelAgentRequired.description": "ááŽáááĩ á¨áááá á ááĩ ááĒá áĨá áá´á ááá¨áĄáĸ",
|
||||
|
||||
@@ -198,7 +198,6 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "ŲŲ
اذØŦ Ų
ØŽØĒØ§ØąØŠ ØĒØĒØļŲ
Ų Claude Ų GPT Ų Gemini ŲØ§ŲŲ
Ø˛ŲØ¯",
|
||||
"dialog.provider.opencode.tagline": "ŲŲ
اذØŦ Ų
ŲØĢŲŲØŠ ŲŲ
ØØŗŲØŠ",
|
||||
"dialog.provider.opencodeGo.tagline": "اشØĒØąØ§Ų Ų
ŲØŽŲØļ Ø§ŲØĒŲŲŲØŠ ŲŲØŦŲ
ŲØš",
|
||||
"dialog.provider.anthropic.note": "ŲØĩŲŲ Ų
Ø¨Ø§Ø´Øą ØĨŲŲ ŲŲ
اذØŦ ClaudeØ Ø¨Ų
ا ŲŲŲØ§ Pro Ų Max",
|
||||
"dialog.provider.copilot.note": "ŲŲ
اذØŦ Ø°ŲØ§ØĄ اØĩØˇŲØ§ØšŲ ŲŲŲ
ØŗØ§ØšØ¯ØŠ ŲŲ Ø§ŲØ¨ØąŲ
ØŦØŠ ØšØ¨Øą GitHub Copilot",
|
||||
"dialog.provider.openai.note": "ŲŲ
اذØŦ GPT ŲŲ
ŲØ§Ų
Ø§ŲØ°ŲØ§ØĄ Ø§ŲØ§ØĩØˇŲØ§ØšŲ Ø§ŲØšØ§Ų
ØŠ Ø§ŲØŗØąŲؚ؊ ŲØ§ŲŲ
ØĒŲØ¯Ų
ØŠ",
|
||||
"dialog.provider.google.note": "ŲŲ
اذØŦ Gemini ŲØ§ØŗØĒØŦاباØĒ ØŗØąŲØšØŠ ŲŲ
ŲØ¸Ų
ØŠ",
|
||||
@@ -215,7 +214,6 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "ØšØąØļ ØŖŲØĢØą Ų
Ų 70 Ų
ŲŲØąŲا ØĨØļاŲŲŲØ§",
|
||||
"dialog.provider.viewAll": "ØšØąØļ اŲŲ
Ø˛ŲØ¯ Ų
Ų Ø§ŲŲ
ŲŲØąŲŲ",
|
||||
"provider.connect.title": "Ø§ŲØ§ØĒØĩØ§Ų Ø¨Ų {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "ØĒØŗØŦŲŲ Ø§ŲØ¯ØŽŲŲ Ø¨Ø§ØŗØĒ؎داŲ
Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "ØØ¯Ø¯ ØˇØąŲŲØŠ ØĒØŗØŦŲŲ Ø§ŲØ¯ØŽŲŲ ŲŲ {{provider}}.",
|
||||
"provider.connect.method.apiKey": "Ų
ŲØĒØ§Ø API",
|
||||
"provider.connect.method.browser": "اŲŲ
ØĒØĩŲØ",
|
||||
@@ -380,9 +378,7 @@ export const dict = {
|
||||
"prompt.attachment.remove": "ØĨØ˛Ø§ŲØŠ Ø§ŲŲ
ØąŲŲ",
|
||||
"prompt.action.send": "ØĨØąØŗØ§Ų",
|
||||
"prompt.action.stop": "ØĨŲŲØ§Ų",
|
||||
"prompt.toast.pasteUnsupported.title": "Ų
ØąŲŲ ØēŲØą Ų
دؚŲŲ
",
|
||||
"prompt.toast.attachmentDuplicate.title": "ØĒŲ
ØĒØŲ
ŲŲ ŲØ°Ø§ اŲŲ
ŲŲ Ø¨Ø§ŲŲØšŲ",
|
||||
"prompt.toast.pasteUnsupported.description": "ŲŲ
ŲŲ ØĨØąŲØ§Ų Ø§ŲØĩŲØą ØŖŲ Ų
ŲŲØ§ØĒ PDF ØŖŲ Ø§ŲŲ
ŲŲØ§ØĒ اŲŲØĩŲØŠ ŲŲØˇ ŲŲØ§.",
|
||||
"prompt.toast.modelAgentRequired.title": "ØØ¯Ø¯ ŲŲŲŲØ§Ų ŲŲŲ
ŲØ°ØŦاŲ",
|
||||
"prompt.toast.modelAgentRequired.description": "ا؎ØĒØą ŲŲŲŲØ§Ų ŲŲŲ
ŲØ°ØŦØ§Ų ŲØ¨Ų ØĨØąØŗØ§Ų Ø§ŲŲ
ŲØŦŲ.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "ŲØ´Ų ØĨŲØ´Ø§ØĄ Ø´ØŦØąØŠ Ø§ŲØšŲ
Ų",
|
||||
|
||||
@@ -194,7 +194,6 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Claude, GPT, Gemini vÉ daha çoxu daxil olmaqla seçilmiÅ modellÉr",
|
||||
"dialog.provider.opencode.tagline": "EtibarlÄą optimallaÅdÄąrÄąlmÄąÅ modellÉr",
|
||||
"dialog.provider.opencodeGo.tagline": "HamÄą ÃŧçÃŧn aÅaÄÄą qiymÉtli abunÉlik",
|
||||
"dialog.provider.anthropic.note": "Pro vÉ Max daxil olmaqla Claude modellÉrinÉ birbaÅa giriÅ",
|
||||
"dialog.provider.copilot.note": "GitHub Copilot vasitÉsilÉ kodlaÅdÄąrma yardÄąmÄą ÃŧçÃŧn AI modellÉri",
|
||||
"dialog.provider.openai.note": "SÃŧrÉtli vÉ bacarÄąqlÄą Ãŧmumi AI tapÅÄąrÄąqlarÄą ÃŧçÃŧn GPT modellÉri",
|
||||
"dialog.provider.google.note": "SÃŧrÉtli, strukturlaÅdÄąrÄąlmÄąÅ cavablar ÃŧçÃŧn Gemini modellÉri",
|
||||
@@ -211,7 +210,6 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Daha 70+ provayderÉ baxÄąn",
|
||||
"dialog.provider.viewAll": "Daha çox provayder gÃļstÉr",
|
||||
"provider.connect.title": "{{provider}} provayderini qoÅ",
|
||||
"provider.connect.title.anthropicProMax": "Claude Pro/Max ilÉ daxil ol",
|
||||
"provider.connect.selectMethod": "{{provider}} ÃŧçÃŧn giriÅ Ãŧsulunu seçin.",
|
||||
"provider.connect.method.apiKey": "API açarÄą",
|
||||
"provider.connect.method.browser": "Brauzer",
|
||||
@@ -378,8 +376,6 @@ export const dict = {
|
||||
"prompt.attachment.remove": "ÆlavÉni sil",
|
||||
"prompt.action.send": "GÃļndÉr",
|
||||
"prompt.action.stop": "DayandÄąr",
|
||||
"prompt.toast.pasteUnsupported.title": "DÉstÉklÉnmÉyÉn ÉlavÉ",
|
||||
"prompt.toast.pasteUnsupported.description": "Buraya yalnÄąz ÅÉkillÉr, PDF-lÉr vÉ ya mÉtn fayllarÄą ÉlavÉ edilÉ bilÉr.",
|
||||
"prompt.toast.attachmentDuplicate.title": "Bu fayl artÄąq yÃŧklÉnib",
|
||||
"prompt.toast.modelAgentRequired.title": "Agent vÉ model seçin",
|
||||
"prompt.toast.modelAgentRequired.description": "Prompt gÃļndÉrmÉzdÉn ÉvvÉl agent vÉ model seçin.",
|
||||
|
||||
@@ -194,7 +194,6 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "ĐОдйŅаĐŊи ĐŧОдĐĩĐģи, вĐēĐģŅŅиŅĐĩĐģĐŊĐž Claude, GPT, Gemini и Đ´ŅŅĐŗĐ¸",
|
||||
"dialog.provider.opencode.tagline": "ĐадĐĩĐļĐ´ĐŊи ĐžĐŋŅиĐŧиСиŅаĐŊи ĐŧОдĐĩĐģи",
|
||||
"dialog.provider.opencodeGo.tagline": "ĐйОĐŊаĐŧĐĩĐŊŅ ĐŊа ĐŊиŅĐēа ŅĐĩĐŊа Са вŅĐĩĐēи",
|
||||
"dialog.provider.anthropic.note": "ĐиŅĐĩĐēŅĐĩĐŊ Đ´ĐžŅŅŅĐŋ Đ´Đž Claude ĐŧОдĐĩĐģи, вĐēĐģŅŅиŅĐĩĐģĐŊĐž Pro и Max",
|
||||
"dialog.provider.copilot.note": "AI ĐŧОдĐĩĐģи Са ĐŋĐžĐŧĐžŅ ĐŋŅи ĐēОдиŅаĐŊĐĩ ŅŅĐĩС GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT ĐŧОдĐĩĐģи Са ĐąŅŅСи, ŅĐŋĐžŅОйĐŊи ОйŅи СадаŅи Ņ Đ¸ĐˇĐēŅŅŅвĐĩĐŊ иĐŊŅĐĩĐģĐĩĐēŅ",
|
||||
"dialog.provider.google.note": "Gemini ĐŧОдĐĩĐģи Са ĐąŅŅСи, ŅŅŅŅĐēŅŅŅиŅаĐŊи ĐžŅĐŗĐžĐ˛ĐžŅи",
|
||||
@@ -211,7 +210,6 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "ĐиĐļŅĐĩ ĐžŅĐĩ 70+ Đ´ĐžŅŅавŅиŅи",
|
||||
"dialog.provider.viewAll": "ĐĐžĐēаСваĐŊĐĩ ĐŊа ĐžŅĐĩ Đ´ĐžŅŅавŅиŅи",
|
||||
"provider.connect.title": "ХвŅŅĐļĐĩŅĐĩ {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "ĐĐģĐĩСŅĐĩ Ņ Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "ĐСйĐĩŅĐĩŅĐĩ ĐŧĐĩŅОд Са вĐģиСаĐŊĐĩ Са {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API ĐēĐģŅŅ",
|
||||
"provider.connect.method.browser": "ĐŅаŅСŅŅ",
|
||||
@@ -378,8 +376,6 @@ export const dict = {
|
||||
"prompt.attachment.remove": "ĐŅĐĩĐŧаŅ
ваĐŊĐĩ ĐŊа ĐŋŅиĐēаŅĐĩĐŊĐ¸Ņ ŅаКĐģ",
|
||||
"prompt.action.send": "ĐСĐŋŅаŅĐĩŅĐĩ",
|
||||
"prompt.action.stop": "ĐĄĐŋŅĐĩŅĐĩ",
|
||||
"prompt.toast.pasteUnsupported.title": "ĐĐĩĐŋОддŅŅĐļаĐŊ ĐŋŅиĐēаŅĐĩĐŊ ŅаКĐģ",
|
||||
"prompt.toast.pasteUnsupported.description": "ĐĸŅĐē ĐŧĐžĐŗĐ°Ņ Đ´Đ° ŅĐĩ ĐŋŅиĐēаŅĐ˛Đ°Ņ ŅаĐŧĐž иСОйŅаĐļĐĩĐŊиŅ, PDF иĐģи ŅĐĩĐēŅŅОви ŅаКĐģОвĐĩ.",
|
||||
"prompt.toast.attachmentDuplicate.title": "ĐĸОСи ŅаКĐģ вĐĩŅĐĩ Đĩ ĐēаŅĐĩĐŊ",
|
||||
"prompt.toast.modelAgentRequired.title": "ĐСйĐĩŅĐĩŅĐĩ Đ°ĐŗĐĩĐŊŅ Đ¸ ĐŧОдĐĩĐģ",
|
||||
"prompt.toast.modelAgentRequired.description": "ĐСйĐĩŅĐĩŅĐĩ Đ°ĐŗĐĩĐŊŅ Đ¸ ĐŧОдĐĩĐģ, ĐŋŅĐĩди да иСĐŋŅаŅиŅĐĩ ĐŋОдĐēаĐŊа.",
|
||||
|
||||
@@ -192,7 +192,6 @@ export const dict: Record<string, string> = {
|
||||
"dialog.provider.opencode.note": "Claude, GPT, Gemini āĻāĻŦāĻ āĻāϰāĻ āĻ
āύā§āĻ āĻāĻŋāĻā§ āϏāĻš āĻāĻŋāĻāϰā§āĻ āĻāϰāĻž āĻŽāĻĄā§āϞ",
|
||||
"dialog.provider.opencode.tagline": "āύāĻŋāϰā§āĻāϰāϝā§āĻā§āϝ āĻ
āĻĒā§āĻāĻŋāĻŽāĻžāĻāĻ āĻāϰāĻž āĻŽāĻĄā§āϞ",
|
||||
"dialog.provider.opencodeGo.tagline": "āϏāĻŦāĻžāϰ āĻāύā§āϝ āĻāĻŽ āĻāϰāĻā§ āϏāĻžāĻŦāϏā§āĻā§āϰāĻŋāĻĒāĻļāύ",
|
||||
"dialog.provider.anthropic.note": "āĻĒā§āϰ⧠āĻāĻŦāĻ āĻŽā§āϝāĻžāĻā§āϏ āϏāĻš Claude āĻŽāĻĄā§āϞāĻā§āϞāĻŋāϤ⧠āϏāϰāĻžāϏāϰāĻŋ āĻ
ā§āϝāĻžāĻā§āϏā§āϏ",
|
||||
"dialog.provider.copilot.note": "GitHub Copilot āĻāϰ āĻŽāĻžāϧā§āϝāĻŽā§ āĻā§āĻĄāĻŋāĻ āϏāĻšāĻžāϝāĻŧāϤāĻžāϰ āĻāύā§āϝ AI āĻŽāĻĄā§āϞ",
|
||||
"dialog.provider.openai.note": "āĻĻā§āϰā§āϤ, āϏāĻā§āώāĻŽ āϏāĻžāϧāĻžāϰāĻŖ AI āĻāĻžāϰā§āϝāĻā§āϞāĻŋāϰ āĻāύā§āϝ GPT āĻŽāĻĄā§āϞ",
|
||||
"dialog.provider.google.note": "āĻĻā§āϰā§āϤ, āĻāĻžāĻ āĻžāĻŽā§āĻāϤ āĻĒā§āϰāϤāĻŋāĻā§āϰāĻŋāϝāĻŧāĻžāϰ āĻāύā§āϝ Gemini āĻŽāĻĄā§āϞ",
|
||||
@@ -209,7 +208,6 @@ export const dict: Record<string, string> = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "āĻāϰāĻ 70+ āĻĒā§āϰāĻĻāĻžāύāĻāĻžāϰ⧠āĻĻā§āĻā§āύ",
|
||||
"dialog.provider.viewAll": "āĻāϰ⧠āĻĒā§āϰāĻĻāĻžāύāĻāĻžāϰ⧠āĻĻā§āĻāĻžāύ",
|
||||
"provider.connect.title": "{{provider}} āϏāĻāϝā§āĻ āĻāϰā§āύ",
|
||||
"provider.connect.title.anthropicProMax": "Claude Pro/Max āĻĻāĻŋāϝāĻŧā§ āϞāĻāĻāύ āĻāϰā§āύ",
|
||||
"provider.connect.selectMethod": "{{provider}} āĻāϰ āĻāύā§āϝ āϞāĻāĻāύ āĻĒāĻĻā§āϧāϤāĻŋ āύāĻŋāϰā§āĻŦāĻžāĻāύ āĻāϰā§āύāĨ¤",
|
||||
"provider.connect.method.apiKey": "API āĻā§",
|
||||
"provider.connect.method.browser": "āĻŦā§āϰāĻžāĻāĻāĻžāϰ",
|
||||
@@ -375,8 +373,6 @@ export const dict: Record<string, string> = {
|
||||
"prompt.attachment.remove": "āϏāĻāϝā§āĻā§āϤāĻŋ āϏāϰāĻžāύ",
|
||||
"prompt.action.send": "āĻĒāĻžāĻ āĻžāύ",
|
||||
"prompt.action.stop": "āĻĨāĻžāĻŽā§",
|
||||
"prompt.toast.pasteUnsupported.title": "āĻ
āϏāĻŽāϰā§āĻĨāĻŋāϤ āϏāĻāϝā§āĻā§āϤāĻŋ",
|
||||
"prompt.toast.pasteUnsupported.description": "āĻāĻāĻžāύ⧠āĻļā§āϧā§āĻŽāĻžāϤā§āϰ āĻāĻŦāĻŋ, āĻĒāĻŋāĻĄāĻŋāĻāĻĢ āĻŦāĻž āĻā§āĻā§āϏāĻ āĻĢāĻžāĻāϞ āϏāĻāϝā§āĻā§āϤ āĻāϰāĻž āϝāĻžāĻŦā§āĨ¤",
|
||||
"prompt.toast.attachmentDuplicate.title": "āĻāĻ āĻĢāĻžāĻāϞ āĻāϤāĻŋāĻŽāϧā§āϝ⧠āĻāĻĒāϞā§āĻĄ āĻāϰāĻž āĻšāϝāĻŧā§āĻā§",
|
||||
"prompt.toast.modelAgentRequired.title": "āĻāĻāĻāĻŋ āĻāĻā§āύā§āĻ āĻāĻŦāĻ āĻŽāĻĄā§āϞ āύāĻŋāϰā§āĻŦāĻžāĻāύ āĻāϰā§āύ",
|
||||
"prompt.toast.modelAgentRequired.description": "āĻĒā§āϰāĻŽā§āĻĒāĻ āĻĒāĻžāĻ āĻžāύā§āϰ āĻāĻā§ āĻāĻāĻāĻŋ āĻāĻā§āύā§āĻ āĻāĻŦāĻ āĻŽāĻĄā§āϞ āĻŦā§āĻā§ āύāĻŋāύāĨ¤",
|
||||
|
||||
@@ -200,7 +200,6 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
|
||||
"dialog.provider.opencode.tagline": "Modelos otimizados e confiÃĄveis",
|
||||
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
|
||||
"dialog.provider.anthropic.note": "Acesso direto aos modelos Claude, incluindo Pro e Max",
|
||||
"dialog.provider.copilot.note": "Modelos de IA para auxÃlio à programaÃ§ÃŖo pelo GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Modelos GPT rÃĄpidos e avançados para tarefas gerais de IA",
|
||||
"dialog.provider.google.note": "Modelos Gemini para respostas rÃĄpidas e estruturadas",
|
||||
@@ -217,7 +216,6 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Ver mais de 70 provedores",
|
||||
"dialog.provider.viewAll": "Ver mais provedores",
|
||||
"provider.connect.title": "Conectar {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Selecionar mÊtodo de login para {{provider}}.",
|
||||
"provider.connect.method.apiKey": "Chave de API",
|
||||
"provider.connect.method.browser": "Navegador",
|
||||
@@ -382,9 +380,7 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Remover anexo",
|
||||
"prompt.action.send": "Enviar",
|
||||
"prompt.action.stop": "Parar",
|
||||
"prompt.toast.pasteUnsupported.title": "Anexo nÃŖo suportado",
|
||||
"prompt.toast.attachmentDuplicate.title": "Este arquivo jÃĄ foi enviado",
|
||||
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
|
||||
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
|
||||
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user