mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 23:06:25 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cbead5dd5 | ||
|
|
c7586c0479 | ||
|
|
5aabcf324d |
+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)
|
||||
- [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)
|
||||
- [ ] 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)
|
||||
|
||||
## 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/info` with operation ID `server.info`.
|
||||
- **Replacement:** `GET /api/status` with operation ID `server.status`.
|
||||
- **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/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 001–002 | `GET` | `/api/status` | `server.status` | 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. |
|
||||
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
|
||||
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
|
||||
| [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] 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] | `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] 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. |
|
||||
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
|
||||
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
|
||||
| [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.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
|
||||
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.rename` | Change | Title-only rename now uses the session resource path. |
|
||||
| [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/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
|
||||
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside 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] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] | `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. |
|
||||
| [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`. |
|
||||
| [ ] 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` | | |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|
|
||||
| [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. |
|
||||
| [ ] 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` | | |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|
|
||||
| [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. |
|
||||
| [ ] 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` | | |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|
|
||||
| [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. |
|
||||
| [ ] 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. |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -93,6 +93,7 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -113,7 +114,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +178,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +254,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +281,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +298,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +322,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +342,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -353,7 +354,6 @@
|
||||
"@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,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +514,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +560,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +575,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +644,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +659,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +683,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +704,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +726,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +761,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +815,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +834,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +880,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +894,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +929,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +964,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +997,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1038,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
@@ -168,7 +168,7 @@ else
|
||||
|
||||
package_scope="@opencode"
|
||||
if [ -z "$requested_version" ]; then
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/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-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
|
||||
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
|
||||
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
|
||||
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -168,10 +168,6 @@ 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,18 +336,12 @@ 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 OpenResponsesErrorObject = Schema.Struct({
|
||||
const OpenResponsesErrorPayload = 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])
|
||||
@@ -440,9 +434,7 @@ export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
|
||||
@@ -715,10 +715,7 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
|
||||
|
||||
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
|
||||
const options = OpenAIOptions.resolve(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
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
return {
|
||||
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
|
||||
// For providers that support `store`, ensure stateless `store:false` is sent
|
||||
|
||||
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
// 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 QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -95,8 +87,7 @@ 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
|
||||
// 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
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/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
|
||||
@@ -152,11 +143,7 @@ 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 (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
if (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)
|
||||
@@ -176,12 +163,10 @@ 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,3 +1,4 @@
|
||||
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"
|
||||
@@ -60,6 +61,11 @@ 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")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -129,7 +135,7 @@ export const configure = (input: Config) => {
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredRoute(chatRoute, input, modelID)
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } })
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
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, supportsPromptCacheKey: true },
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
|
||||
}),
|
||||
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, supportsPromptCacheKey: true },
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
|
||||
@@ -41,8 +41,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
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, supportsPromptCacheKey: true },
|
||||
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false },
|
||||
})
|
||||
const messages = (modelID: string | ModelID) =>
|
||||
configuredMessages.model<MessagesOptionsInput>({
|
||||
|
||||
@@ -112,7 +112,6 @@ export const configure = (input: Config = {}) => {
|
||||
maxTokensField: "max_tokens",
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
supportsPromptCacheKey: true,
|
||||
toolSchema: "moonshot",
|
||||
reasoningField: "reasoning_content",
|
||||
},
|
||||
|
||||
@@ -91,10 +91,7 @@ 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,
|
||||
compatibility: { supportsPromptCacheKey: true },
|
||||
})
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
OpenAIImages.model({
|
||||
id: modelID,
|
||||
|
||||
@@ -183,8 +183,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
route.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,9 +168,6 @@ 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("does not let server codes override a 4xx rejection", () =>
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
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: "InvalidRequest" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,54 +249,10 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[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,
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
),
|
||||
).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")
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
|
||||
import { WebSocketTransport } from "../../src/route.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -45,59 +44,11 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
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,
|
||||
})
|
||||
}
|
||||
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" })
|
||||
}),
|
||||
)
|
||||
|
||||
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,23 +248,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
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", () =>
|
||||
it.effect("maps the request prompt cache key", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -277,7 +261,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
expect(prepared.body.prompt_cache_key).toBe("session_123")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -296,7 +280,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () =>
|
||||
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
|
||||
@@ -310,8 +294,7 @@ 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()))
|
||||
// Chat uses the header; prompt_cache_key is Responses-only.
|
||||
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBeUndefined()
|
||||
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
|
||||
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, Logger, Ref, Schema, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
@@ -760,11 +760,7 @@ 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)
|
||||
@@ -799,10 +795,7 @@ 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 },
|
||||
@@ -811,10 +804,7 @@ 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]],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -910,10 +900,7 @@ 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,
|
||||
)
|
||||
@@ -964,10 +951,7 @@ 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(
|
||||
@@ -1098,9 +1082,7 @@ 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)
|
||||
@@ -1142,9 +1124,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// 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", () =>
|
||||
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
@@ -1153,13 +1133,13 @@ describe("OpenAI Responses route", () => {
|
||||
const cases = [
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
|
||||
headers: { "api-key": "azure-key", authorization: undefined },
|
||||
authorization: "Bearer azure-key",
|
||||
},
|
||||
{
|
||||
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
|
||||
"deployment",
|
||||
),
|
||||
headers: { "api-key": undefined, authorization: "Bearer entra-token" },
|
||||
authorization: "Bearer entra-token",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1170,8 +1150,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.headers.authorization)
|
||||
expect(exchange.connect.headers["api-key"]).toBe(item.headers["api-key"])
|
||||
expect(exchange.connect.headers.authorization).toBe(item.authorization)
|
||||
expect(exchange.connect.headers["api-key"]).toBeUndefined()
|
||||
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||
type: "response.create",
|
||||
@@ -1219,11 +1199,6 @@ 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") },
|
||||
@@ -1239,9 +1214,6 @@ 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"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1870,11 +1842,7 @@ 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." }] },
|
||||
@@ -4631,20 +4599,8 @@ 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,18 +42,3 @@ 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,17 +9,12 @@ 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", "0.16")
|
||||
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
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("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 expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
|
||||
|
||||
await logo.evaluate((element) => {
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => {
|
||||
@@ -82,7 +77,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", "0.16")
|
||||
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
|
||||
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,7 +485,6 @@ export function stepStarted(message: SessionMessageAssistant) {
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
started: message.time.created,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,7 @@ 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/info")
|
||||
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
|
||||
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
|
||||
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,15 +108,6 @@ 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")
|
||||
@@ -305,12 +296,7 @@ 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"; currentDirectory?: string } = {},
|
||||
) {
|
||||
const currentDirectory = options.currentDirectory ?? directory
|
||||
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
@@ -329,7 +315,7 @@ async function openDraft(
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: currentDirectory,
|
||||
directory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
@@ -456,7 +442,7 @@ async function openDraft(
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
@@ -469,12 +455,12 @@ async function openDraft(
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
|
||||
{ directory, 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/branch?*", (route) => {
|
||||
await page.route("**/api/vcs/branches?*", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
|
||||
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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: { decision: "once" },
|
||||
body: { reply: "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: { decision: "once" },
|
||||
body: { reply: "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: { decision: "once" },
|
||||
body: { reply: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { decision: "once" },
|
||||
body: { reply: "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: { decision: "once" },
|
||||
body: { reply: "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: { decision: "once" },
|
||||
body: { reply: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -16,13 +16,8 @@ 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/info") {
|
||||
return json(route, {
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: [url.origin],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
if (url.pathname === "/api/status") {
|
||||
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
|
||||
}
|
||||
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" | "queue" }) => {
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
|
||||
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 = input.action
|
||||
row.delivery = "steer"
|
||||
emit("session.inbox.delivery.changed", {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.inboxID,
|
||||
delivery: input.action,
|
||||
delivery: "steer",
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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, started: Date.now() })
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
|
||||
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, started: Date.now() })
|
||||
mock.emit("session.step.started", { ...later, agent: "build", model })
|
||||
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,10 +48,7 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
|
||||
)
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
@@ -110,7 +107,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({ decision: "once" })
|
||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
|
||||
@@ -138,14 +138,7 @@ 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"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
},
|
||||
}),
|
||||
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ 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/info")
|
||||
const response = await fetch("/api/status")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -172,6 +172,64 @@ 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")
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
|
||||
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,11 +278,7 @@ async function installMotionProbe(page: Page) {
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (
|
||||
panelGap &&
|
||||
reviewRegion.getBoundingClientRect().height > 1 &&
|
||||
terminalRegion.getBoundingClientRect().height > 1
|
||||
)
|
||||
if (panelGap && 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/info")
|
||||
if (path === "/api/status")
|
||||
return void response
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
|
||||
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,13 +334,8 @@ 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/info`)
|
||||
expect(await api?.json()).toEqual({
|
||||
version: "test",
|
||||
pid: 1,
|
||||
urls: ["http://localhost"],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
})
|
||||
const api = await page.goto(`${site.url}/api/status`)
|
||||
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
|
||||
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("info", "/api/info", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("status", "/api/status", { 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", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { 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/branch", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { 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.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
|
||||
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
|
||||
params: { ...SessionParams, formID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
@@ -218,9 +218,8 @@ const Group = HttpApiGroup.make("mock")
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
|
||||
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
|
||||
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" | "queue" }) => void
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
|
||||
}
|
||||
|
||||
type MockStreamWindow = Window & {
|
||||
@@ -219,13 +219,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
}),
|
||||
)
|
||||
.handleAll({
|
||||
info: () =>
|
||||
Effect.succeed({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: config.server ? [config.server] : [],
|
||||
paths: { tmp: "/tmp/opencode" },
|
||||
}),
|
||||
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
|
||||
config: () => Effect.succeed(configEntries),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
@@ -460,13 +454,9 @@ 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)),
|
||||
sessionInboxUpdate: (ctx) =>
|
||||
sessionInboxSteer: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({
|
||||
sessionID: ctx.params.sessionID,
|
||||
inboxID: ctx.params.inboxID,
|
||||
action: ctx.payload.delivery,
|
||||
}),
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionSwitchAgent: () => noContent,
|
||||
sessionSwitchModel: () => noContent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -93,6 +93,7 @@
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3"
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-16
@@ -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 } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -107,21 +107,29 @@ export function AppInterface(props: {
|
||||
// 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) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
|
||||
@@ -16,86 +16,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
[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,12 +1,66 @@
|
||||
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 { ComposerPrompt } from "../types"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
|
||||
import type { AttachmentDestination } from "./destination"
|
||||
import { uploads } from "./uploads"
|
||||
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",
|
||||
]
|
||||
|
||||
type PromptTarget = {
|
||||
current: () => ComposerPrompt
|
||||
@@ -20,11 +74,10 @@ 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
|
||||
@@ -49,23 +102,13 @@ export function createComposerAttachments(
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
|
||||
}
|
||||
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
|
||||
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
|
||||
|
||||
// A file the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Anything else reaches the model as a path on the server and never enters the store:
|
||||
// hashing and copying a large archive through it is what used to freeze the window.
|
||||
const add = async (file: File, target = capture(), clipboard = false) => {
|
||||
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
const destination = input.destination()
|
||||
if (native(mime, destination.input)) 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) => {
|
||||
if (!mime) {
|
||||
if (toast) input.warn()
|
||||
return false
|
||||
}
|
||||
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
|
||||
@@ -84,45 +127,24 @@ export function createComposerAttachments(
|
||||
input.duplicate()
|
||||
return true
|
||||
}
|
||||
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
|
||||
const attachment: ComposerAttachment = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
blob,
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
}
|
||||
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 addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||
const found = await files.reduce(async (result, file) => {
|
||||
const previous = await result
|
||||
return (await add(file, target)) || previous
|
||||
return (await add(file, false, target)) || previous
|
||||
}, Promise.resolve(false))
|
||||
if (!found && files.length > 0 && toast) input.warn()
|
||||
return found
|
||||
}
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
@@ -137,13 +159,13 @@ export function createComposerAttachments(
|
||||
return file ? [file] : []
|
||||
})
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files, target)
|
||||
await addAttachments(files, true, target)
|
||||
return
|
||||
}
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
const file = await input.readClipboardImage()
|
||||
if (file && (await add(file, target, true))) return
|
||||
if (file && (await add(file, true, target, true))) return
|
||||
}
|
||||
if (!plainText) return
|
||||
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
|
||||
@@ -196,31 +218,20 @@ 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 }, (file) => add(file)).catch(input.onError)
|
||||
void input
|
||||
.picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
|
||||
.catch(input.onError)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
// Mirrors the attachment kinds the server forwards to the model as message content.
|
||||
function native(mime: string, input: AttachmentDestination["input"]) {
|
||||
if (mime === "text/plain") return true
|
||||
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"],
|
||||
@@ -238,8 +249,6 @@ const textMimes = new Set([
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
// Text-like files normalize to text/plain so the server inlines their content; every other
|
||||
// file keeps a binary type and is delivered to the model by path or as native media.
|
||||
async function attachmentMime(file: File) {
|
||||
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
if (imageMimes.has(type) || type === "application/pdf") return type
|
||||
@@ -250,11 +259,10 @@ 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 binary
|
||||
if (bytes.some((byte) => byte === 0)) return
|
||||
const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
|
||||
if (bytes.length > 0 && control / bytes.length > 0.3) return binary
|
||||
if (bytes.length > 0 && control / bytes.length > 0.3) return
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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,13 +8,6 @@ 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)
|
||||
@@ -65,17 +58,8 @@ 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
|
||||
@@ -96,10 +80,6 @@ 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 { isAttachment, promptLength } from "../prompt-parts"
|
||||
import { 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(isAttachment),
|
||||
...state.prompt.filter((part) => part.type === "image"),
|
||||
],
|
||||
cursor: content.length,
|
||||
retry: undefined,
|
||||
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
clearRetry()
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
|
||||
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || 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 (isAttachment(part)) return [part]
|
||||
if (part.type === "image") 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 (isAttachment(part)) return [part]
|
||||
if (part.type === "image") 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 (isAttachment(part)) return part
|
||||
if (part.type === "image") return part
|
||||
const next = { ...part, start: offset, end: offset + part.content.length }
|
||||
offset = next.end
|
||||
return next
|
||||
|
||||
@@ -12,8 +12,6 @@ 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"
|
||||
@@ -26,7 +24,6 @@ import type {
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
import "./editor.css"
|
||||
|
||||
@@ -105,6 +102,7 @@ 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
|
||||
@@ -151,13 +149,11 @@ 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)}
|
||||
/>
|
||||
@@ -196,9 +192,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onInput={(event) => {
|
||||
const cursor = composerCursor(event.currentTarget)
|
||||
const prompt = parseComposerEditor(event.currentTarget)
|
||||
const attachments = props.controller.parts().filter(isAttachment)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
@@ -353,7 +349,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
|
||||
const active = document.activeElement === editor
|
||||
editor.replaceChildren(
|
||||
...prompt.flatMap<Node>((part) => {
|
||||
if (isAttachment(part)) return []
|
||||
if (part.type === "image") return []
|
||||
if (part.type === "text") return [document.createTextNode(part.content)]
|
||||
const mention = document.createElement("span")
|
||||
mentionParts.set(mention, part)
|
||||
@@ -480,22 +476,17 @@ 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.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
|
||||
>
|
||||
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
|
||||
<div
|
||||
data-slot="composer-attachments-scroll"
|
||||
@@ -532,30 +523,22 @@ export function ComposerAttachments(props: {
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<Tooltip
|
||||
value={attachment.type === "path" ? attachment.path : attachment.filename}
|
||||
placement="top"
|
||||
contentClass="break-all"
|
||||
>
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<Show
|
||||
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
{(image) => (
|
||||
<>
|
||||
<img
|
||||
src={image().blob.url}
|
||||
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" />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
@@ -569,28 +552,6 @@ 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"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,7 +2,6 @@ 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,
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
type ComposerInteractionCommand,
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
import type { ComposerQueue } from "../adapter"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
@@ -75,7 +74,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 (isAttachment(part)) return false
|
||||
if (part.type === "image") return false
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
draft.addMention(part)
|
||||
return true
|
||||
@@ -170,7 +169,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(isAttachment),
|
||||
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
|
||||
0,
|
||||
)
|
||||
}
|
||||
@@ -316,13 +315,7 @@ export function createComposerEditor(input: {
|
||||
return draft.state.context.items.filter((item) => !!item.comment?.trim())
|
||||
},
|
||||
attachments(): ComposerAttachment[] {
|
||||
return draft.state.prompt.filter(isAttachment)
|
||||
},
|
||||
uploads(): Upload[] {
|
||||
return attachments?.pending() ?? []
|
||||
},
|
||||
cancelUpload(id: string) {
|
||||
attachments?.cancel(id)
|
||||
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
|
||||
},
|
||||
toggleContext(id: string) {
|
||||
dispatch({ type: "context.active", id })
|
||||
@@ -343,12 +336,11 @@ 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(isAttachment)) return true
|
||||
if (persisted.prompt.some((part) => part.type === "image")) return true
|
||||
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
},
|
||||
@@ -377,7 +369,6 @@ 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" })
|
||||
},
|
||||
@@ -393,20 +384,20 @@ export function createComposerEditor(input: {
|
||||
},
|
||||
onPaste(event: ClipboardEvent) {
|
||||
const clipboard = event.clipboardData
|
||||
const text = clipboard?.getData("text/plain")
|
||||
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
|
||||
if (
|
||||
attachments &&
|
||||
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
|
||||
) {
|
||||
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 normalized = text.replace(/\r\n?/g, "\n")
|
||||
const multiline = normalized.includes("\n")
|
||||
const value = multiline
|
||||
? normalized.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
: normalized
|
||||
const multiline = text.includes("\n")
|
||||
const value = multiline ? text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") : text
|
||||
if (
|
||||
typeof document.execCommand === "function" &&
|
||||
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
|
||||
@@ -417,13 +408,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(normalized)
|
||||
const node = document.createTextNode(text)
|
||||
range.insertNode(node)
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
|
||||
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
|
||||
},
|
||||
onDragEnter(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
@@ -459,12 +450,6 @@ 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 type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt, isAttachment } from "../prompt-parts"
|
||||
import { clonePrompt } 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 hasAttachments = prompt.some(isAttachment)
|
||||
const hasImages = prompt.some((part) => part.type === "image")
|
||||
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
||||
if (!text && !hasAttachments && !hasComments) return entries
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
|
||||
const entry = {
|
||||
prompt: clonePrompt(prompt),
|
||||
@@ -86,7 +86,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 (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
|
||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
}
|
||||
if (entryA.comments.length !== entryB.comments.length) return false
|
||||
for (let i = 0; i < entryA.comments.length; i++) {
|
||||
|
||||
@@ -17,12 +17,11 @@ 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 { isAttachment } from "./prompt-parts"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
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"]
|
||||
@@ -73,7 +72,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}, [])
|
||||
})
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter(isAttachment),
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
@@ -320,10 +319,8 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, adapter.controls(), layout, files, comments)
|
||||
@@ -341,15 +338,13 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
attachments: {
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk().directory,
|
||||
destination: useAttachmentDestination(adapter.controls),
|
||||
isDialogActive: () => !!dialog.active,
|
||||
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
||||
onUploadError: (error) =>
|
||||
warn: () =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("prompt.toast.uploadFailed.title"),
|
||||
description: composerErrorMessage(language, error),
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
}),
|
||||
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
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"
|
||||
}
|
||||
import type { Prompt } from "./state"
|
||||
|
||||
export function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map((part) =>
|
||||
@@ -22,7 +17,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
|
||||
...clonePrompt(prompt),
|
||||
{ type: "text", content: "\n\n", start, end: offset },
|
||||
...clonePrompt(following).map((part) =>
|
||||
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import type { 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 = [
|
||||
@@ -25,7 +21,9 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [inline("a.png", "image/png")],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -47,7 +45,16 @@ describe("buildPromptRequest", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
|
||||
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",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
@@ -63,9 +70,14 @@ describe("buildPromptRequest", () => {
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
inline("opencode.global.dat", "text/plain", {
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
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,13 +1,8 @@
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import {
|
||||
formatAttachmentReference,
|
||||
formatCommentNote,
|
||||
type PromptAttachmentReference,
|
||||
type PromptComment,
|
||||
} from "@/composer/comment-note"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
@@ -17,7 +12,6 @@ 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 = {
|
||||
@@ -62,7 +56,6 @@ 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) => ({
|
||||
@@ -113,27 +106,18 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
return [file, ...mentions]
|
||||
})
|
||||
|
||||
const inline = input.images.map((attachment) => ({
|
||||
const images = 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] : []),
|
||||
...attachments.map(formatAttachmentReference),
|
||||
...comments.map(formatCommentNote),
|
||||
].join("\n"),
|
||||
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...inline],
|
||||
files: [...files, ...context, ...images],
|
||||
agents,
|
||||
skills,
|
||||
comments,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,24 +94,7 @@ export const ImageAttachmentPart = Schema.Struct({
|
||||
)
|
||||
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
|
||||
|
||||
// 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 const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
|
||||
export type ContentPart = typeof ContentPart.Type
|
||||
export const Prompt = Persistence.array(ContentPart)
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
@@ -23,7 +23,6 @@ export type {
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
SkillPart,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
|
||||
import { appendPrompt, clonePrompt } 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) => isAttachment(part) || part.content.length > 0))
|
||||
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
}
|
||||
if (!following) target.reset()
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
const submitting = new WeakSet<object>()
|
||||
@@ -152,9 +151,6 @@ 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()
|
||||
? [
|
||||
@@ -189,7 +185,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() && !prompt.some(isAttachment) && comments === 0) return
|
||||
if (!text.trim() && images.length === 0 && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
@@ -362,7 +358,6 @@ async function sendPrompt(
|
||||
metadata: {
|
||||
displayText: request.displayText,
|
||||
comments: request.comments,
|
||||
attachments: request.attachments,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
@@ -377,15 +372,19 @@ 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),
|
||||
})),
|
||||
)
|
||||
return buildPromptRequest({
|
||||
const request = buildPromptRequest({
|
||||
prompt: value.prompt,
|
||||
context: value.context,
|
||||
images,
|
||||
text: value.text,
|
||||
sessionDirectory: session.directory,
|
||||
})
|
||||
return request
|
||||
}
|
||||
|
||||
function failSubmission(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
|
||||
export type ComposerInteractionState = {
|
||||
mode: "normal" | "shell"
|
||||
@@ -237,7 +236,7 @@ function populated(persisted: ComposerPersistedState) {
|
||||
return (
|
||||
!!promptText(persisted).trim() ||
|
||||
persisted.context.items.length > 0 ||
|
||||
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
|
||||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
FileAttachmentPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
SkillPart,
|
||||
} from "./state"
|
||||
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
|
||||
|
||||
export type ComposerFilePart = FileAttachmentPart
|
||||
export type ComposerAgentPart = AgentPart
|
||||
export type ComposerSkillPart = SkillPart
|
||||
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
|
||||
export type ComposerAttachment = ImageAttachmentPart
|
||||
export type ComposerPrompt = Prompt
|
||||
export type ComposerComment = ComposerStore["context"]["items"][number]
|
||||
export type ComposerPersistedState = ComposerStore
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -14,7 +15,6 @@ 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"
|
||||
|
||||
@@ -146,7 +146,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const next = title.trim()
|
||||
if (!next || next === sessionLabel(session)) return true
|
||||
return ctx.sdk.api.session
|
||||
.update({ sessionID: session.id, title: next })
|
||||
.rename({ 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
|
||||
|
||||
@@ -42,14 +42,8 @@
|
||||
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),
|
||||
[data-slot="new-session-content"] {
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
|
||||
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -59,8 +53,7 @@
|
||||
}
|
||||
|
||||
[data-slot="session-chat-panel"][data-summary-resizing="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div),
|
||||
[data-component="new-session"][data-summary-resizing="true"] [data-slot="new-session-content"] {
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -77,18 +70,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,10 +9,6 @@ export function useNewSessionCommands(input: {
|
||||
empty: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
workspace: {
|
||||
enabled: () => boolean
|
||||
cycle: () => void
|
||||
}
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
@@ -44,13 +40,5 @@ 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,8 +48,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
submitted: props.submitted,
|
||||
async start(selection, submission, message) {
|
||||
const draftID = props.draftID
|
||||
const currentDirectory = location().directory
|
||||
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
|
||||
const projectDirectory = location().directory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
|
||||
@@ -60,10 +60,6 @@ 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,10 +1,8 @@
|
||||
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, onMount } from "solid-js"
|
||||
import { Show, Suspense, createMemo, createSignal, lazy } 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"
|
||||
@@ -50,21 +48,7 @@ export function NewSessionView(props: {
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
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 [store, setStore] = createStore({ summary: false })
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
WorkspaceOnboardingSchema,
|
||||
@@ -79,9 +63,6 @@ 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
|
||||
@@ -108,11 +89,7 @@ export function NewSessionView(props: {
|
||||
</SummaryPopover>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div
|
||||
ref={(element) => setStore("content", element)}
|
||||
data-slot="new-session-content"
|
||||
class={NEW_SESSION_CONTENT_WIDTH}
|
||||
>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<NewSessionWordmark />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} />
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
[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;
|
||||
@@ -18,10 +13,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
[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 { Logo } from "@opencode/ui/logo"
|
||||
import { Wordmark } from "@opencode/ui/wordmark"
|
||||
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 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 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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
cycleNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionGit,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./controller"
|
||||
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
|
||||
|
||||
describe("new session workspace selection", () => {
|
||||
test("uses main when the workspace bar is unavailable", () => {
|
||||
@@ -69,16 +64,4 @@ 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,12 +37,6 @@ 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
|
||||
@@ -55,10 +49,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
existing: undefined as { projectID: string; directory: string } | undefined,
|
||||
})
|
||||
const [state, setState] = createStore({ search: "" })
|
||||
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
@@ -151,7 +142,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
|
||||
({ directory, search }) =>
|
||||
serverSDK.api.vcs
|
||||
.branch.list({ location: { directory }, search, limit: 50 })
|
||||
.branches({ location: { directory }, search, limit: 50 })
|
||||
.then((response) => ({ directory, search, data: response.data }))
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
@@ -168,8 +159,6 @@ 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(() =>
|
||||
@@ -187,20 +176,6 @@ 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: {
|
||||
@@ -218,8 +193,11 @@ export function createNewSessionWorkspaceController(input: {
|
||||
input.setSelectedBranch(undefined)
|
||||
},
|
||||
remember,
|
||||
set: select,
|
||||
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
|
||||
set: (worktree: string) => {
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(worktree)
|
||||
remember(worktree)
|
||||
},
|
||||
create: (branch: string) => {
|
||||
input.setSelectedBranch(branch)
|
||||
input.setSelectedWorktree("create")
|
||||
|
||||
@@ -191,6 +191,7 @@ 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 ለፈጣን፣ የተዋቀሩ ምላሾች ሞዴሎች",
|
||||
@@ -207,6 +208,7 @@ 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": "አሳሽ",
|
||||
@@ -369,6 +371,8 @@ 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,6 +198,7 @@ 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 لاستجابات سريعة ومنظمة",
|
||||
@@ -214,6 +215,7 @@ 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,7 +380,9 @@ 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,6 +194,7 @@ 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",
|
||||
@@ -210,6 +211,7 @@ 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",
|
||||
@@ -376,6 +378,8 @@ 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,6 +194,7 @@ 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 модели за бързи, структурирани отговори",
|
||||
@@ -210,6 +211,7 @@ 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": "Браузър",
|
||||
@@ -376,6 +378,8 @@ 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,6 +192,7 @@ 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 মডেল",
|
||||
@@ -208,6 +209,7 @@ 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": "ব্রাউজার",
|
||||
@@ -373,6 +375,8 @@ 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,6 +200,7 @@ 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",
|
||||
@@ -216,6 +217,7 @@ 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",
|
||||
@@ -380,7 +382,9 @@ 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",
|
||||
|
||||
@@ -208,6 +208,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
|
||||
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
|
||||
"dialog.provider.opencodeGo.tagline": "Povoljna pretplata za sve",
|
||||
"dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max",
|
||||
"dialog.provider.copilot.note": "AI modeli za pomoć pri kodiranju putem GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke",
|
||||
"dialog.provider.google.note": "Gemini modeli za brze, strukturirane odgovore",
|
||||
@@ -228,6 +229,7 @@ export const dict = {
|
||||
"dialog.provider.viewAll": "Prikaži više provajdera",
|
||||
|
||||
"provider.connect.title": "Poveži {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API ključ",
|
||||
"provider.connect.method.browser": "Preglednik",
|
||||
@@ -401,7 +403,9 @@ export const dict = {
|
||||
"prompt.action.send": "Pošalji",
|
||||
"prompt.action.stop": "Zaustavi",
|
||||
|
||||
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
|
||||
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
|
||||
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
|
||||
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
|
||||
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",
|
||||
|
||||
@@ -193,6 +193,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Models seleccionats que inclouen Claude, GPT, Gemini i més",
|
||||
"dialog.provider.opencode.tagline": "Models optimitzats fiables",
|
||||
"dialog.provider.opencodeGo.tagline": "Subscripció de baix cost per a tothom",
|
||||
"dialog.provider.anthropic.note": "Accés directe a Claude models, inclosos Pro i Max",
|
||||
"dialog.provider.copilot.note": "Models d'IA per a l'assistència de codificació mitjançant GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT models per a tasques d'IA generals ràpides i capaces",
|
||||
"dialog.provider.google.note": "Gemini models per a respostes ràpides i estructurades",
|
||||
@@ -209,6 +210,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Consulta més de 70 proveïdors més",
|
||||
"dialog.provider.viewAll": "Mostra més proveïdors",
|
||||
"provider.connect.title": "Connecta {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Inicieu sessió amb Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Seleccioneu el mètode d'inici de sessió per a {{provider}}.",
|
||||
"provider.connect.method.apiKey": "tecla API.",
|
||||
"provider.connect.method.browser": "Navegador",
|
||||
@@ -375,6 +377,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Elimina el fitxer adjunt",
|
||||
"prompt.action.send": "Enviar",
|
||||
"prompt.action.stop": "Atureu-vos",
|
||||
"prompt.toast.pasteUnsupported.title": "Fitxer adjunt no compatible",
|
||||
"prompt.toast.pasteUnsupported.description": "Aquí només es poden adjuntar imatges, PDFs o fitxers de text.",
|
||||
"prompt.toast.attachmentDuplicate.title": "Aquest fitxer ja s'ha penjat",
|
||||
"prompt.toast.modelAgentRequired.title": "Seleccioneu un agent i un model",
|
||||
"prompt.toast.modelAgentRequired.description": "Trieu un agent i un model abans d'enviar una sol·licitud.",
|
||||
|
||||
@@ -191,6 +191,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Vybrané modely včetně Claude, GPT, Gemini a dalších",
|
||||
"dialog.provider.opencode.tagline": "Spolehlivé optimalizované modely",
|
||||
"dialog.provider.opencodeGo.tagline": "Nízkonákladové předplatné pro každého",
|
||||
"dialog.provider.anthropic.note": "Přímý přístup k modelům Claude, včetně modelů Pro a Max",
|
||||
"dialog.provider.copilot.note": "Modely umělé inteligence pro pomoc s kódováním prostřednictvím GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT modely pro rychlé a schopné obecné úlohy umělé inteligence",
|
||||
"dialog.provider.google.note": "Gemini modely pro rychlé, strukturované odpovědi",
|
||||
@@ -207,6 +208,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Zobrazit více než 70 dalších poskytovatelů",
|
||||
"dialog.provider.viewAll": "Zobrazit další poskytovatele",
|
||||
"provider.connect.title": "Připojit {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Přihlaste se pomocí Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Vyberte způsob přihlášení pro {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API klíč",
|
||||
"provider.connect.method.browser": "Prohlížeč",
|
||||
@@ -373,6 +375,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Odstraňte přílohu",
|
||||
"prompt.action.send": "Odeslat",
|
||||
"prompt.action.stop": "Přestaň",
|
||||
"prompt.toast.pasteUnsupported.title": "Nepodporovaná příloha",
|
||||
"prompt.toast.pasteUnsupported.description": "Zde lze připojit pouze obrázky, PDFs nebo textové soubory.",
|
||||
"prompt.toast.attachmentDuplicate.title": "Tento soubor již byl nahrán",
|
||||
"prompt.toast.modelAgentRequired.title": "Vyberte agenta a model",
|
||||
"prompt.toast.modelAgentRequired.description": "Před odesláním výzvy vyberte zástupce a model.",
|
||||
|
||||
@@ -107,6 +107,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
|
||||
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
|
||||
"dialog.provider.opencodeGo.tagline": "Billigt abonnement for alle",
|
||||
"dialog.provider.anthropic.note": "Direkte adgang til Claude-modeller, inklusive Pro og Max",
|
||||
"dialog.provider.copilot.note": "AI-modeller til kodningsassistance via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT-modeller til hurtige, kompetente generelle AI-opgaver",
|
||||
"dialog.provider.google.note": "Gemini-modeller til hurtige, strukturerede svar",
|
||||
@@ -127,6 +128,7 @@ export const dict = {
|
||||
"dialog.provider.viewAll": "Vis flere udbydere",
|
||||
|
||||
"provider.connect.title": "Forbind {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API-nøgle",
|
||||
"provider.connect.method.browser": "Browser",
|
||||
@@ -298,7 +300,9 @@ export const dict = {
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
|
||||
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
|
||||
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
|
||||
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
|
||||
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
|
||||
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",
|
||||
|
||||
@@ -103,6 +103,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Kuratierte Modelle inklusive Claude, GPT, Gemini und mehr",
|
||||
"dialog.provider.opencode.tagline": "Zuverlässige, optimierte Modelle",
|
||||
"dialog.provider.opencodeGo.tagline": "Kostengünstiges Abo für alle",
|
||||
"dialog.provider.anthropic.note": "Direkter Zugriff auf Claude-Modelle, einschließlich Pro und Max",
|
||||
"dialog.provider.copilot.note": "KI-Modelle zur Programmierunterstützung über GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT-Modelle für schnelle, leistungsfähige allgemeine KI-Aufgaben",
|
||||
"dialog.provider.google.note": "Gemini-Modelle für schnelle, strukturierte Antworten",
|
||||
@@ -119,6 +120,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Über 70 weitere Anbieter anzeigen",
|
||||
"dialog.provider.viewAll": "Mehr Anbieter anzeigen",
|
||||
"provider.connect.title": "{{provider}} verbinden",
|
||||
"provider.connect.title.anthropicProMax": "Mit Claude Pro/Max anmelden",
|
||||
"provider.connect.selectMethod": "Anmeldemethode für {{provider}} auswählen.",
|
||||
"provider.connect.method.apiKey": "API-Schlüssel",
|
||||
"provider.connect.method.browser": "Browser",
|
||||
@@ -284,7 +286,9 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Anhang entfernen",
|
||||
"prompt.action.send": "Senden",
|
||||
"prompt.action.stop": "Stoppen",
|
||||
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
|
||||
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
|
||||
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
|
||||
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
|
||||
"prompt.toast.modelAgentRequired.description":
|
||||
"Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",
|
||||
|
||||
@@ -195,6 +195,7 @@ export const dict = {
|
||||
"ކިއުރެޓް ކުރެވިފައިވާ މޮޑެލްތަކުގެ ތެރޭގައި 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 މޮޑެލްތަކެވެ",
|
||||
@@ -211,6 +212,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "އިތުރު 70+ ޕްރޮވައިޑަރުން ބައްލަވާށެވެ",
|
||||
"dialog.provider.viewAll": "އިތުރު ޕްރޮވައިޑަރުން ދައްކާށެވެ",
|
||||
"provider.connect.title": "{{provider}} ގުޅުވާށެވެ",
|
||||
"provider.connect.title.anthropicProMax": "Claude ޕްރޯ/މެކްސް އިން ލޮގިން ވާނެއެވެ",
|
||||
"provider.connect.selectMethod": "{{provider}} އަށް ލޮގިން މެތޯޑް ހޮވާށެވެ.",
|
||||
"provider.connect.method.apiKey": "API ކީ އެވެ",
|
||||
"provider.connect.method.browser": "ބްރައުޒާ އެވެ",
|
||||
@@ -378,6 +380,9 @@ export const dict = {
|
||||
"prompt.attachment.remove": "އެޓޭޗްމަންޓް ނަގާށެވެ",
|
||||
"prompt.action.send": "ފޮނުވުން",
|
||||
"prompt.action.stop": "ހުއްޓުން",
|
||||
"prompt.toast.pasteUnsupported.title": "ސަޕޯޓް ނުކުރާ އެޓޭޗްމަންޓެވެ",
|
||||
"prompt.toast.pasteUnsupported.description":
|
||||
"މިތަނުގައި އެޓޭޗް ކުރެވޭނީ ހަމައެކަނި ތަސްވީރު، PDFs، ނުވަތަ ޓެކްސްޓް ފައިލްތަކެވެ.",
|
||||
"prompt.toast.attachmentDuplicate.title": "މި ފައިލް މިހާރު ވަނީ އަޕްލޯޑްކޮށްފައެވެ",
|
||||
"prompt.toast.modelAgentRequired.title": "އޭޖެންޓަކާއި މޮޑެލްއެއް ހޮވުން",
|
||||
"prompt.toast.modelAgentRequired.description": "ޕްރޮމްޕްޓެއް ފޮނުވުމުގެ ކުރިން އޭޖެންޓަކާއި މޮޑެލްއެއް ހޮވުން.",
|
||||
|
||||
@@ -194,6 +194,7 @@ 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": "GPT མགྱོགས་དྲགས་དང་ལྕོགས་གྲུབ་ཅན་གྱི་སྤྱིར་བཏང་ཨེ་ཨའི་ལས་འགན་ཚུ་གི་དོན་ལུ་དཔེ་ཚད།",
|
||||
"dialog.provider.google.note": "Gemini མགྱོགས་དྲགས་དང་སྒྲིག་བཀོད་ཅན་གྱི་ལན་འདེབས་ཀྱི་དཔེ་ཚད།",
|
||||
@@ -211,6 +212,7 @@ 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": "བརའུ་ཟར།",
|
||||
@@ -377,6 +379,9 @@ 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": "བརྡ་སྟོན་མ་གཏང་པའི་ཧེ་མ་ ལས་ཚབ་དང་དཔེ་ཚད་གདམ་ཁ་རྐྱབས།",
|
||||
|
||||
@@ -192,6 +192,7 @@ 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 μοντέλα για γρήγορες, δομημένες απαντήσεις",
|
||||
@@ -208,6 +209,7 @@ 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": "Πρόγραμμα περιήγησης",
|
||||
@@ -374,6 +376,8 @@ 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":
|
||||
|
||||
@@ -74,7 +74,6 @@ export const dict = {
|
||||
"command.category.workspace": "Worktree",
|
||||
"command.category.settings": "Settings",
|
||||
"command.logs.export": "Export logs",
|
||||
"command.debugBar.toggle": "Toggle debug bar",
|
||||
|
||||
"theme.scheme.system": "System",
|
||||
"theme.scheme.light": "Light",
|
||||
@@ -94,7 +93,6 @@ export const dict = {
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archive session",
|
||||
"command.session.location.cycle": "Cycle session location",
|
||||
|
||||
"command.palette": "Command palette",
|
||||
|
||||
@@ -171,7 +169,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
||||
"dialog.provider.opencode.tagline": "Reliable optimized models",
|
||||
"dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone",
|
||||
"dialog.provider.anthropic.note": "Direct access to Claude models via API key",
|
||||
"dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max",
|
||||
"dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
|
||||
"dialog.provider.google.note": "Gemini models for fast, structured responses",
|
||||
@@ -192,7 +190,7 @@ export const dict = {
|
||||
"dialog.provider.viewAll": "Show more providers",
|
||||
|
||||
"provider.connect.title": "Connect {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Login with Anthropic",
|
||||
"provider.connect.title.anthropicProMax": "Login with Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Select login method for {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API key",
|
||||
"provider.connect.method.browser": "Browser",
|
||||
@@ -366,10 +364,9 @@ export const dict = {
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
|
||||
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
|
||||
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
|
||||
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
|
||||
"prompt.toast.uploading.percent": "{{percent}}%",
|
||||
"prompt.toast.uploading.cancel": "Cancel upload",
|
||||
"prompt.toast.uploadFailed.title": "Upload failed",
|
||||
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
|
||||
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
|
||||
@@ -440,6 +437,36 @@ export const dict = {
|
||||
"No camera is available to this browser. Enter your connection details manually.",
|
||||
"server.connect.camera.error":
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
"command.server.pair": "Pair device",
|
||||
"settings.pairing.title": "Pairing",
|
||||
"settings.pairing.sshUnavailable": "Pairing is not available for SSH servers.",
|
||||
"settings.pairing.connection": "Local Network",
|
||||
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
|
||||
"pair.local.open": "Show details",
|
||||
"pair.screenActive.title": "Keep screen active",
|
||||
"pair.screenActive.description": "Prevent this computer’s display from sleeping while OpenCode is running.",
|
||||
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
|
||||
"pair.qr.open": "Show details",
|
||||
"pair.tailscale.enable": "Enable Tailscale",
|
||||
"pair.tailscale.serve": "Tailscale Serve",
|
||||
"pair.description": "Connect another device to this machine's OpenCode server.",
|
||||
"pair.loading": "Loading connection details…",
|
||||
"pair.qr": "Pairing QR code",
|
||||
"pair.copy": "Copy details",
|
||||
"pair.copy.error": "Could not copy pairing details. Try again.",
|
||||
"pair.urls": "URLs",
|
||||
"pair.username": "Username",
|
||||
"pair.password": "Password",
|
||||
"pair.error": "Could not update pairing details. Try again.",
|
||||
"pair.tailscale.open": "Share with Tailscale",
|
||||
"pair.tailscale.opening": "Enabling Tailscale Serve…",
|
||||
"pair.tailscale.title": "Tailscale",
|
||||
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
|
||||
"pair.tailscale.checking": "Checking Tailscale Serve…",
|
||||
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
|
||||
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
|
||||
"pair.tailscale.disable": "Disable",
|
||||
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
@@ -967,16 +994,7 @@ export const dict = {
|
||||
"sidebar.empty.description": "Open a project to get started",
|
||||
|
||||
"debugBar.ariaLabel": "Development performance diagnostics",
|
||||
"debugBar.providerAriaLabel": "Provider performance diagnostics",
|
||||
"debugBar.na": "n/a",
|
||||
"debugBar.ttft.label": "TTFT",
|
||||
"debugBar.ttft.tip": "Time from provider request dispatch to the first model output.",
|
||||
"debugBar.ttfa.label": "TTFA",
|
||||
"debugBar.ttfa.tip": "Time from provider request dispatch to the first answer text.",
|
||||
"debugBar.tps.label": "TPS",
|
||||
"debugBar.tps.tip": "Output tokens per second after the first model output.",
|
||||
"debugBar.e2e.label": "E2E",
|
||||
"debugBar.e2e.tip": "Time from provider request dispatch until its response stream ended.",
|
||||
"debugBar.nav.label": "NAV",
|
||||
"debugBar.nav.tip":
|
||||
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
|
||||
|
||||
@@ -208,6 +208,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Selección de modelos como Claude, GPT, Gemini y otros",
|
||||
"dialog.provider.opencode.tagline": "Modelos optimizados y fiables",
|
||||
"dialog.provider.opencodeGo.tagline": "Suscripción económica para todos",
|
||||
"dialog.provider.anthropic.note": "Acceso directo a modelos Claude, incluidos Pro y Max",
|
||||
"dialog.provider.copilot.note": "Modelos de IA para asistencia de codificación a través de GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Modelos GPT para tareas de IA generales rápidas y capaces",
|
||||
"dialog.provider.google.note": "Modelos Gemini para respuestas rápidas y estructuradas",
|
||||
@@ -228,6 +229,7 @@ export const dict = {
|
||||
"dialog.provider.viewAll": "Ver más proveedores",
|
||||
|
||||
"provider.connect.title": "Conectar {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Iniciar sesión con Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Seleccionar método de inicio de sesión para {{provider}}.",
|
||||
"provider.connect.method.apiKey": "Clave API",
|
||||
"provider.connect.method.browser": "Navegador",
|
||||
@@ -401,7 +403,10 @@ export const dict = {
|
||||
"prompt.action.send": "Enviar",
|
||||
"prompt.action.stop": "Detener",
|
||||
|
||||
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
|
||||
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
|
||||
"prompt.toast.pasteUnsupported.description":
|
||||
"Aquí solo se pueden adjuntar imágenes, archivos PDF o archivos de texto.",
|
||||
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
|
||||
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo",
|
||||
|
||||
@@ -191,6 +191,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Kureeritud mudelid, sealhulgas Claude, GPT, Gemini ja palju muud",
|
||||
"dialog.provider.opencode.tagline": "Usaldusväärsed optimeeritud mudelid",
|
||||
"dialog.provider.opencodeGo.tagline": "Madala hinnaga tellimus kõigile",
|
||||
"dialog.provider.anthropic.note": "Otsene juurdepääs Claude mudelile, sealhulgas Pro ja Max",
|
||||
"dialog.provider.copilot.note": "AI mudelid kodeerimisabiks GitHub Copilot kaudu",
|
||||
"dialog.provider.openai.note": "GPT mudelid kiirete ja võimekate üldiste AI-ülesannete jaoks",
|
||||
"dialog.provider.google.note": "Gemini mudelid kiirete ja struktureeritud vastuste jaoks",
|
||||
@@ -207,6 +208,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Vaadake veel 70+ pakkujat",
|
||||
"dialog.provider.viewAll": "Kuva rohkem teenusepakkujaid",
|
||||
"provider.connect.title": "Ühenda {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Logi sisse Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Valige {{provider}} jaoks sisselogimisviis.",
|
||||
"provider.connect.method.apiKey": "API klahv",
|
||||
"provider.connect.method.browser": "Brauser",
|
||||
@@ -372,6 +374,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Eemalda manus",
|
||||
"prompt.action.send": "Saada",
|
||||
"prompt.action.stop": "Peatus",
|
||||
"prompt.toast.pasteUnsupported.title": "Toetamata manus",
|
||||
"prompt.toast.pasteUnsupported.description": "Siia saab lisada ainult pilte, PDFs või tekstifaile.",
|
||||
"prompt.toast.attachmentDuplicate.title": "See fail on juba üles laaditud",
|
||||
"prompt.toast.modelAgentRequired.title": "Valige agent ja mudel",
|
||||
"prompt.toast.modelAgentRequired.description": "Enne viipa saatmist valige agent ja mudel.",
|
||||
|
||||
@@ -192,6 +192,7 @@ 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 برای کارهای عمومی سریع و با قابلیت AI",
|
||||
"dialog.provider.google.note": "مدل های Gemini برای پاسخ های سریع و ساختاریافته",
|
||||
@@ -208,6 +209,7 @@ 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": "مرورگر",
|
||||
@@ -373,6 +375,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "حذف پیوست",
|
||||
"prompt.action.send": "ارسال کنید",
|
||||
"prompt.action.stop": "توقف کنید",
|
||||
"prompt.toast.pasteUnsupported.title": "پیوست پشتیبانی نشده است",
|
||||
"prompt.toast.pasteUnsupported.description": "فقط تصاویر، PDFs، یا فایل های متنی را می توان در اینجا پیوست کرد.",
|
||||
"prompt.toast.attachmentDuplicate.title": "این فایل قبلا آپلود شده است",
|
||||
"prompt.toast.modelAgentRequired.title": "یک عامل و مدل را انتخاب کنید",
|
||||
"prompt.toast.modelAgentRequired.description": "قبل از ارسال درخواست، یک عامل و مدل را انتخاب کنید.",
|
||||
|
||||
@@ -99,6 +99,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Kuratoituja malleja, kuten Claude, GPT, Gemini ja muita",
|
||||
"dialog.provider.opencode.tagline": "Luotettavat optimoidut mallit",
|
||||
"dialog.provider.opencodeGo.tagline": "Edullinen tilaus kaikille",
|
||||
"dialog.provider.anthropic.note": "Suora pääsy Claude-malleihin, mukaan lukien Pro ja Max",
|
||||
"dialog.provider.copilot.note": "AI-malleja koodausapuun GitHub Copilotin kautta",
|
||||
"dialog.provider.openai.note": "GPT-mallit nopeisiin, suorituskykyisiin yleisiin tekoälytehtäviin",
|
||||
"dialog.provider.google.note": "Gemini-mallit nopeisiin, jäsenneltyihin vastauksiin",
|
||||
@@ -115,6 +116,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Katso yli 70 muuta palveluntarjoajaa",
|
||||
"dialog.provider.viewAll": "Näytä lisää palveluntarjoajia",
|
||||
"provider.connect.title": "Yhdistä {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Kirjaudu sisään Claude Pro/Maxilla",
|
||||
"provider.connect.selectMethod": "Valitse kirjautumistapa palvelulle {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API-avain",
|
||||
"provider.connect.method.browser": "Selain",
|
||||
@@ -280,6 +282,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Poista liite",
|
||||
"prompt.action.send": "Lähetä",
|
||||
"prompt.action.stop": "Pysäytä",
|
||||
"prompt.toast.pasteUnsupported.title": "Liitettä ei tueta",
|
||||
"prompt.toast.pasteUnsupported.description": "Vain kuvia, PDF-tiedostoja tai tekstitiedostoja voi liittää tähän.",
|
||||
"prompt.toast.attachmentDuplicate.title": "Tämä tiedosto on jo ladattu",
|
||||
"prompt.toast.modelAgentRequired.title": "Valitse agentti ja malli",
|
||||
"prompt.toast.modelAgentRequired.description": "Valitse agentti ja malli ennen kehotteen lähettämistä.",
|
||||
|
||||
@@ -191,6 +191,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Kuraterað modell íroknað Claude, GPT, Gemini og fleiri",
|
||||
"dialog.provider.opencode.tagline": "Álítandi optimerað modell",
|
||||
"dialog.provider.opencodeGo.tagline": "Lágkostnaðarhald fyri øll",
|
||||
"dialog.provider.anthropic.note": "Beinleiðis atgongd til Claude modellir, eisini Pro og Max .",
|
||||
"dialog.provider.copilot.note": "AI modellir til koduhjálp umvegis GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT modellir til skjótar, førar almennar AI uppgávur",
|
||||
"dialog.provider.google.note": "Gemini modellir til skjót, strukturerað svar",
|
||||
@@ -207,6 +208,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Sí 70+ fleiri veitarar",
|
||||
"dialog.provider.viewAll": "Vís fleiri veitarar",
|
||||
"provider.connect.title": "Samband {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Rita inn við Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Vel innritanarhátt til {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API lykil",
|
||||
"provider.connect.method.browser": "Kagari",
|
||||
@@ -372,6 +374,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Strika viðheftið",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Steðga",
|
||||
"prompt.toast.pasteUnsupported.title": "Óstuðlað viðhefti",
|
||||
"prompt.toast.pasteUnsupported.description": "Bert myndir, PDFs, ella tekstfílur kunnu viðheftast her.",
|
||||
"prompt.toast.attachmentDuplicate.title": "Hendan fílan er longu løgd upp.",
|
||||
"prompt.toast.modelAgentRequired.title": "Vel agent og modell",
|
||||
"prompt.toast.modelAgentRequired.description": "Vel agent og modell, áðrenn tú sendir ein prompt.",
|
||||
|
||||
@@ -200,6 +200,7 @@ export const dict = {
|
||||
"dialog.provider.opencode.note": "Modèles sélectionnés incluant Claude, GPT, Gemini et plus",
|
||||
"dialog.provider.opencode.tagline": "Modèles optimisés et fiables",
|
||||
"dialog.provider.opencodeGo.tagline": "Abonnement abordable pour tous",
|
||||
"dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API",
|
||||
"dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API",
|
||||
"dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API",
|
||||
"dialog.provider.google.note": "Modèles Gemini pour des réponses rapides et structurées",
|
||||
@@ -216,6 +217,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "Voir plus de 70 autres fournisseurs",
|
||||
"dialog.provider.viewAll": "Voir plus de fournisseurs",
|
||||
"provider.connect.title": "Connecter {{provider}}",
|
||||
"provider.connect.title.anthropicProMax": "Connexion avec Claude Pro/Max",
|
||||
"provider.connect.selectMethod": "Sélectionnez la méthode de connexion pour {{provider}}.",
|
||||
"provider.connect.method.apiKey": "Clé API",
|
||||
"provider.connect.method.browser": "Navigateur",
|
||||
@@ -383,7 +385,10 @@ export const dict = {
|
||||
"prompt.attachment.remove": "Supprimer la pièce jointe",
|
||||
"prompt.action.send": "Envoyer",
|
||||
"prompt.action.stop": "Arrêter",
|
||||
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
|
||||
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
|
||||
"prompt.toast.pasteUnsupported.description":
|
||||
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
|
||||
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
|
||||
"prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer une invite.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail",
|
||||
|
||||
@@ -192,6 +192,7 @@ 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 למשימות AI כלליות, מהירות ומתקדמות",
|
||||
"dialog.provider.google.note": "מודלי Gemini לתגובות מהירות ומובנות",
|
||||
@@ -208,6 +209,7 @@ 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,6 +373,8 @@ 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": "יש לבחור סוכן ומודל לפני שליחת פרומפט.",
|
||||
|
||||
@@ -199,6 +199,7 @@ 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": "GitHub Copilot के माध्यम से कोडिंग सहायता के लिए AI मॉडल",
|
||||
"dialog.provider.openai.note": "तेज़, सक्षम सामान्य AI कार्यों के लिए GPT मॉडल",
|
||||
"dialog.provider.google.note": "तेज़, संरचित प्रतिक्रियाओं के लिए Gemini मॉडल",
|
||||
@@ -215,6 +216,7 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "70+ अधिक प्रोवाइडर देखें",
|
||||
"dialog.provider.viewAll": "और प्रोवाइडर दिखाएँ",
|
||||
"provider.connect.title": "{{provider}} कनेक्ट करें",
|
||||
"provider.connect.title.anthropicProMax": "Claude प्रो/मैक्स के साथ लॉगिन करें",
|
||||
"provider.connect.selectMethod": "{{provider}} के लिए लॉगिन विधि चुनें।",
|
||||
"provider.connect.method.apiKey": "API कुंजी",
|
||||
"provider.connect.method.browser": "ब्राउज़र",
|
||||
@@ -380,6 +382,8 @@ export const dict = {
|
||||
"prompt.attachment.remove": "अनुलग्नक हटाएँ",
|
||||
"prompt.action.send": "भेजें",
|
||||
"prompt.action.stop": "रोकें",
|
||||
"prompt.toast.pasteUnsupported.title": "असमर्थित अनुलग्नक",
|
||||
"prompt.toast.pasteUnsupported.description": "यहां केवल छवियां, PDFs, या टेक्स्ट फ़ाइलें संलग्न की जा सकती हैं।",
|
||||
"prompt.toast.attachmentDuplicate.title": "यह फ़ाइल पहले ही अपलोड की जा चुकी है",
|
||||
"prompt.toast.modelAgentRequired.title": "एक एजेंट और मॉडल चुनें",
|
||||
"prompt.toast.modelAgentRequired.description": "प्रॉम्प्ट भेजने से पहले एक एजेंट और मॉडल चुनें।",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user