Compare commits

..
280 changed files with 6764 additions and 5559 deletions
+83 -83
View File
@@ -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
@@ -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. |
+1 -1
View File
@@ -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",
@@ -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:*",
+4 -4
View File
@@ -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="
}
}
@@ -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 }),
+1 -4
View File
@@ -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
+7 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
}
+1 -1
View File
@@ -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,
}
+1 -2
View File
@@ -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,
}
}
+1 -1
View File
@@ -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>({
-1
View File
@@ -112,7 +112,6 @@ export const configure = (input: Config = {}) => {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
supportsPromptCacheKey: true,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
+1 -4
View File
@@ -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,
+1 -2
View File
@@ -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,
}
}
-3
View File
@@ -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. */
+4 -21
View File
@@ -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" }] }],
])
}),
)
@@ -1,19 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("shows question navigation shortcuts", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
const next = component.getByRole("button", { name: "Next", exact: true })
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await next.click()
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
const back = component.getByRole("button", { name: "Back", exact: true })
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
await expect(back).toHaveText("Back")
await back.hover()
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
await page.keyboard.press(
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
)
await expect(next).toBeVisible()
})
@@ -158,10 +158,6 @@ for (const direction of ["ltr", "rtl"]) {
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 900 })
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
await expect(component.locator('[data-slot="timeline-detail-scale"]')).toHaveCSS("margin-top", "16px")
const advanced = component.locator('[data-slot="timeline-detail-advanced"]')
await expect(advanced).toHaveCSS("margin-top", "0px")
await expect(advanced).toHaveCSS("padding-top", "8px")
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
"color",
await component
@@ -181,46 +177,28 @@ for (const direction of ["ltr", "rtl"]) {
),
)
if (theme === "light") {
await expect(track.locator("span").last()).toHaveCSS("background-image", "none")
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
await expect(track).toHaveCSS(
"--timeline-detail-marker-background",
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
)
}
if (theme === "dark") {
await expect(track.locator("span").last()).not.toHaveCSS("background-image", "none")
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
}
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
const list = component.locator('[data-slot="timeline-detail-list"]')
const columns = component.locator('[data-slot="timeline-detail-columns"]')
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
const advancedTrigger = component.getByRole("button", { name: "Advanced", exact: true })
expect(
await advancedTrigger.evaluate((element) => {
const trigger = element.getBoundingClientRect()
const heading = document
.querySelector('[data-slot="timeline-detail-columns"] > :nth-child(2)')!
.getBoundingClientRect()
return Math.abs(trigger.y + trigger.height / 2 - (heading.y + heading.height / 2))
}),
).toBeLessThan(1)
expect(
await list.evaluate((element) => {
const list = element.getBoundingClientRect()
const trigger = document
.querySelector('[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"]')!
.getBoundingClientRect()
return list.y - trigger.bottom
}),
).toBe(8)
for (const [column, field] of [
[2, "placement"],
[3, "details"],
] as const) {
const heading = await columns.locator(`> :nth-child(${column})`).evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const heading = await component
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const toggle = await component
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
.evaluate((element) => {
@@ -229,7 +207,7 @@ for (const direction of ["ltr", "rtl"]) {
})
expect(Math.abs(heading - toggle)).toBeLessThan(1)
}
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "8px")
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
for (const width of [900, 320]) {
await page.setViewportSize({ width, height: 900 })
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
@@ -249,7 +227,6 @@ for (const direction of ["ltr", "rtl"]) {
const slider = component.getByRole("slider", { name: "Timeline detail" })
const track = component.locator('[data-slot="timeline-detail-track"]')
await expect(slider).not.toHaveCSS("cursor", "pointer")
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
await component
.locator('[data-slot="timeline-detail-scale"]')
@@ -260,11 +237,6 @@ for (const direction of ["ltr", "rtl"]) {
for (const position of [0, 1, 2, 3, 4]) {
if (position > 0) await slider.press("ArrowUp")
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
await expect(track.locator("span[data-selected]")).toHaveCount(position + 1)
await expect(track.locator("span").nth(position)).toHaveCSS(
"background-color",
await track.evaluate((element) => getComputedStyle(element, "::before").backgroundColor),
)
const fill = await track.evaluate((element) => {
const style = getComputedStyle(element, "::before")
return {
@@ -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" },
},
])
})
@@ -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",
})
},
}
@@ -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 }) => {
@@ -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")
@@ -5,7 +5,7 @@ for (const colorScheme of ["light", "dark"] as const) {
test.describe(colorScheme, () => {
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
test("project list actions and edges stay inside the settings scrollport", async ({ page }, info) => {
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
id: `project-${index}`,
name,
@@ -40,45 +40,18 @@ for (const colorScheme of ["light", "dark"] as const) {
const panel = settings.getByRole("tabpanel")
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
const list = panel.locator('[data-component="settings-list"]')
await expect(list).toHaveAttribute("data-variant", "catalog")
await expect(list).toHaveCSS("padding-left", "16px")
await expect(list).toHaveCSS("padding-right", "16px")
const projectButton = panel.getByRole("button", { name: "rebase", exact: true })
const projectRow = projectButton.locator("..")
const projectName = projectButton.getByText("rebase", { exact: true })
await expect(projectName).toHaveCSS("font-weight", "530")
await expect(projectName).toHaveCSS("line-height", "20px")
await expect(projectRow).toHaveCSS("padding-top", "16px")
await expect(projectRow).toHaveCSS("padding-bottom", "16px")
expect(
Math.abs(
(await projectButton.evaluate((element) => element.getBoundingClientRect().height)) -
(await projectRow.evaluate((element) => element.getBoundingClientRect().height)),
),
).toBeLessThanOrEqual(1)
const chevron = projectButton.locator('svg:has(use[href="#opencode-v2-icon-chevron-right"])')
await expect(chevron).toHaveCSS("opacity", "0")
await projectRow.hover()
await expect(chevron).toHaveCSS("opacity", "1")
const more = projectRow.getByRole("button", { name: "More options", exact: true })
await more.click()
const menu = page.getByRole("menu")
await expect(menu.getByRole("menuitem")).toHaveText(["New session", "Clear notifications", "Close"])
await expect(menu.getByRole("separator")).toHaveCount(1)
await panel.getByRole("heading", { name: "Projects", exact: true }).click()
await expect(menu).toBeHidden()
await page.evaluate(() => document.fonts.ready)
for (const width of [1280, 1050, 960, 720, 600]) {
await page.setViewportSize({ width, height: 720 })
await page.mouse.move(0, 0)
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
// The catalog card and its rows stay fully inside every horizontal clip ancestor.
// Raised cards paint a half-pixel border outside their box. The scrollport
// must leave room for that border and the soft shadow on both sides.
await expect
.poll(() =>
projectRow.evaluate((row) => {
panel.getByText("rebase", { exact: true }).evaluate((label) => {
const row = label.parentElement!.parentElement!
const bounds = row.getBoundingClientRect()
const clips = []
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
@@ -100,11 +73,6 @@ for (const colorScheme of ["light", "dark"] as const) {
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
const secondProject = panel.getByRole("button", { name: "dinocms", exact: true })
await secondProject.locator("..").getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(secondProject).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 260 })
await panel.getByText("rebase", { exact: true }).hover()
await page.mouse.wheel(0, 400)
@@ -30,19 +30,6 @@ function projectList(count: number) {
}))
}
async function persistProjects(page: Page, projects: ReturnType<typeof projectList>) {
await page.evaluate((projects) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
...value,
projects: { ...value.projects, local: projects.map((project) => ({ worktree: project.canonical })) },
}),
)
}, projects)
}
function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
@@ -63,14 +50,6 @@ test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, config)
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
await page.addInitScript((directory) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
if (value.projects?.local) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ ...value, projects: { ...value.projects, local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
@@ -193,22 +172,9 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})
test("Shortcuts search keeps focus while filtering", async ({ page }) => {
const view = ui(page)
await view.settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search shortcuts", exact: true })
await search.press("p")
await expect(search).toBeFocused()
await page.keyboard.type("alette")
await expect(search).toHaveValue("palette")
await expect(view.settings.getByText("Command palette", { exact: true })).toBeVisible()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
const inventory = projectList(count)
await page.route("**/api/project", (route) => route.fulfill({ json: inventory }))
await persistProjects(page, inventory)
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
@@ -242,14 +208,24 @@ for (const count of [7, 8]) {
})
}
test("Projects search focuses with a qualifying persisted inventory", async ({ page }) => {
const projects = projectList(8)
await page.route("**/api/project", (route) => route.fulfill({ json: projects }))
await persistProjects(page, projects)
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 })
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
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)
})
@@ -289,13 +265,11 @@ test("qualified project results preserve query and selection on return", async (
})
test("returning from a project restores a scrolled result list", async ({ page }) => {
const projects = projectList(30)
await page.route("**/api/project", (route) =>
route.fulfill({
json: projects,
json: projectList(30),
}),
)
await persistProjects(page, projects)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
@@ -397,17 +371,14 @@ test("multi-server results navigate to the named server and hide search in neste
body: Buffer.from(await response.arrayBuffer()),
})
})
await page.addInitScript((server) => {
const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
...value,
list: [{ type: "http", displayName: "Build server", http: { url: server } }],
projects: { ...value.projects, [server]: [{ worktree: "/remote/opencode", expanded: true }] },
}),
)
}, server)
await page.addInitScript(
(server) =>
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
),
server,
)
await page.reload()
const view = ui(page)
await view.search.fill("MCPs")
@@ -422,13 +393,6 @@ test("multi-server results navigate to the named server and hide search in neste
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server projects")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(view.settings.getByRole("button", { name: "Add project", exact: true })).toBeVisible()
await expect(view.settings.locator('[data-component="settings-list"][data-variant="catalog"]')).toBeVisible()
await expect(view.settings.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server OpenCode name")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.results.getByRole("option").click()
@@ -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({
+4 -5
View File
@@ -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,
}),
)
+3 -7
View File
@@ -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 & {
@@ -454,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,
+2 -1
View File
@@ -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
View File
@@ -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 -21
View File
@@ -5,8 +5,6 @@ import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode/client/promise"
import { pathKey } from "@/workspaces/path-key"
import { addProjects } from "./projects/add"
export function createHomeController() {
const layout = useLayout()
@@ -43,24 +41,6 @@ export function createHomeController() {
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
void ctx.sync.worktrees.list(id).then(() => ctx.sync.worktrees.refresh(id))
})
createEffect(() => {
// The project list is empty until the server store hydrates; clearing the restored
// selection against it would persist the loss.
if (!servers.hydrated()) return
const current = selection()
const directory = current.directory
if (!directory) return
const conn = servers.visible.find((conn) => ServerConnection.key(conn) === current.server)
if (!conn) return
if (
global
.ensureServerCtx(conn)
.projects.list()
.some((project) => pathKey(project.worktree) === pathKey(directory))
)
return
setSelection({ server: current.server })
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
@@ -119,8 +99,23 @@ export function createHomeController() {
setSelection(toggleHomeProjectSelection(selection(), key, directory))
},
add: (conn: ServerConnection.Any, directories: string[]) => {
const directory = addProjects(global.ensureServerCtx(conn), directories)
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => {
if (ctx.projects.list().some((project) => project.worktree === item)) return
const location = { directory: item }
void ctx.sdk.api.file
.list({ path: ".", location })
.then(async (files) => {
// TODO: Initialize empty directories when V2 exposes a native Git init API.
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
})
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
.catch(() => undefined)
ctx.projects.open(item)
})
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
},
openNewSession: () => {
-19
View File
@@ -1,19 +0,0 @@
import type { ServerCtx } from "@/runtime/server/runtime"
export function addProjects(context: ServerCtx, directories: string[]) {
const directory = directories[0]
if (!directory) return
directories.forEach((item) => {
if (context.projects.list().some((project) => project.worktree === item)) return
const location = { directory: item }
void context.sdk.api.file
.list({ path: ".", location })
.then(() => context.sdk.api.location.get({ location }))
.then((value) => context.sync.child(item, { bootstrap: false })[1]("project", value.project.id))
.catch(() => undefined)
context.projects.open(item)
})
context.projects.touch(directory)
return directory
}
+9 -24
View File
@@ -6,7 +6,7 @@ import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { closeHomeProject, displayName, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode/ui/context/dialog"
@@ -17,7 +17,6 @@ import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
import { revealProject } from "./reveal"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -44,7 +43,7 @@ export function createHomeProjectsController(home: HomeController) {
}
function canRevealProject(conn: ServerConnection.Any) {
return platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn)
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function choose(conn: ServerConnection.Any) {
@@ -168,27 +167,13 @@ export function createHomeProjectsController(home: HomeController) {
},
canReveal: canRevealProject,
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.revealPath || !canRevealProject(conn)) return
const context = global.ensureServerCtx(conn)
void revealProject({
directory: project.worktree,
reveal: platform.revealPath,
remove: context.projects.remove,
})
.then((revealed) => {
if (revealed) return
showToast({
variant: "error",
title: language.t("home.project.missing.title"),
description: language.t("home.project.missing.description", { name: displayName(project) }),
})
})
.catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
},
},
utility: {
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test"
import { revealProject } from "./reveal"
describe("reveal project", () => {
test("keeps a project when its directory is revealed", async () => {
const removed: string[] = []
expect(
await revealProject({
directory: "/projects/current",
reveal: async () => true,
remove: (directory) => removed.push(directory),
}),
).toBe(true)
expect(removed).toEqual([])
})
test("removes a project when its directory no longer exists", async () => {
const removed: string[] = []
expect(
await revealProject({
directory: "/projects/missing",
reveal: async () => false,
remove: (directory) => removed.push(directory),
}),
).toBe(false)
expect(removed).toEqual(["/projects/missing"])
})
})
-9
View File
@@ -1,9 +0,0 @@
export async function revealProject(input: {
directory: string
reveal: (directory: string) => Promise<boolean>
remove: (directory: string) => void
}) {
if (await input.reveal(input.directory)) return true
input.remove(input.directory)
return false
}
@@ -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
-12
View File
@@ -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,
},
])
}
-4
View File
@@ -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,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")
+29 -3
View File
@@ -93,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",
@@ -438,6 +437,35 @@ 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.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 computers 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":
@@ -720,8 +748,6 @@ export const dict = {
"home.projects": "Projects",
"home.projects.all": "All projects",
"home.project.add": "Add project",
"home.project.missing.title": "Project folder not found",
"home.project.missing.description": "{{name}} was removed because its folder no longer exists.",
"home.recentlyClosed": "Recently closed",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
+19 -3
View File
@@ -22,6 +22,12 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
error: string
url: string
@@ -46,9 +52,6 @@ type PlatformBase = {
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Return the local paths that still exist as directories; paths that cannot be checked are kept (desktop only) */
checkDirectories?(paths: string[]): Promise<string[]>
/** Restart the app */
restart(): Promise<void>
@@ -104,6 +107,10 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -127,6 +134,15 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
tailscaleAvailable(): Promise<boolean>
tailscaleStatus(): Promise<PairingInfo | null>
openTailscale(): Promise<PairingInfo>
disableTailscale(): Promise<void>
}
}
export type Platform = PlatformBase &
+1 -4
View File
@@ -205,7 +205,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, hydrated] = persisted(
const [store, setStore, _] = persisted(
{
...Persist.global("server"),
sync: true,
@@ -263,9 +263,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get visible() {
return visibleServers()
},
// Named to avoid the context `ready` gate: consumers that derive from persisted project
// state wait on this, but the provider must not block first render on storage.
hydrated,
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
@@ -98,7 +98,7 @@ describe("createRequestQueue", () => {
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branch")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
+1 -24
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -14,7 +14,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
@@ -110,7 +109,6 @@ function createServerController(
) {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
@@ -168,27 +166,6 @@ function createServerController(
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
// Only the desktop can stat a local server's project directories without a server round-trip.
// Each directory is checked once per app run; the list is tracked so persisted projects are
// checked after the store hydrates.
const checked = new Set<string>()
createEffect(() => {
if (!isLocal || !platform.checkDirectories) return
const directories = projects
.list()
.map((project) => project.worktree)
.filter((directory) => !checked.has(pathKey(directory)))
if (directories.length === 0) return
directories.forEach((directory) => checked.add(pathKey(directory)))
void platform
.checkDirectories(directories)
.then((available) => {
const keep = new Set(available.map(pathKey))
batch(() => directories.filter((directory) => !keep.has(pathKey(directory))).forEach(projects.remove))
})
.catch(() => undefined)
})
return {
data,
sdk,
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://computer.tailnet.ts.net:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
const info = {
urls: ["http://192.168.1.2:4096"],
username: "opencode" as const,
password: "a+b & café",
}
const url = new URL(pairingUrl(info, "https://example.com"))
expect(url.origin).toBe("https://example.com")
expect(url.pathname).toBe("/connect")
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
expect(decodePairingUrl(url.search)).toEqual({
urls: ["http://192.168.1.2:4096"],
password: "a+b & café",
})
})
test("defaults to the hosted app for desktop pairing", () => {
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
"https://app.opencode.ai",
)
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
+33 -3
View File
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
@@ -19,10 +19,40 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string) {
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
const urls = [
...new Set(
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(
value: { urls?: readonly string[]; username: "opencode"; password: string },
host = "https://app.opencode.ai",
) {
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
}
+12 -2
View File
@@ -9,12 +9,15 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { isMixedContent } from "./browser"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
@@ -36,7 +39,13 @@ export function ConnectServerScreen() {
},
{ initialValue: false },
)
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -57,6 +66,7 @@ export function ConnectServerScreen() {
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
+1 -3
View File
@@ -130,9 +130,7 @@ export function createSessionQueue(input: {
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
@@ -115,7 +115,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
+1 -1
View File
@@ -112,7 +112,7 @@ export function createSessionRequestModel() {
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { DockPrompt } from "@opencode/session-ui/dock-prompt"
import { Icon } from "@opencode/ui/icon"
import { useSpring } from "@opencode/ui/motion-spring"
@@ -14,10 +13,8 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey } from "@/runtime/server/scope"
import { useCommand } from "@/shell/commands/command"
const cache = new Map<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
type QuestionField = FormStringField | FormMultiselectField
@@ -73,7 +70,6 @@ function Option(props: {
export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => {
const serverSDK = useServerSDK()
const language = useLanguage()
const command = useCommand()
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const questions = createMemo(() =>
@@ -241,7 +237,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
serverSDK.api.session.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
onMutate: () => {
props.onSubmit()
},
@@ -253,7 +249,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -265,8 +261,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const submitShortcut = () => (IS_MAC ? "⌘⏎" : `${language.t("common.key.ctrl")}+⏎`)
const backShortcut = () => (IS_MAC ? "⌘[" : `${language.t("common.key.alt")}+←`)
const reply = (answer: FormAnswer) => {
if (sending()) return
@@ -357,16 +351,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
return
}
const previous = IS_MAC
? event.metaKey && !event.ctrlKey && !event.altKey && event.key === "["
: event.altKey && !event.ctrlKey && !event.metaKey && event.key === "ArrowLeft"
if (previous) {
if (event.repeat) return
event.preventDefault()
back()
return
}
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
if (mod && event.key === "Enter") {
if (event.repeat) return
@@ -477,19 +461,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (!store.minimized) focus(pickFocus(tab))
}
command.register("session.question.back", () => [
{
id: "session.question.back",
title: language.t("ui.common.back"),
keybind: IS_MAC ? "mod+[" : "alt+arrowleft",
hidden: true,
// Stay registered while sending so the shortcut does not fall through to history navigation.
disabled: store.tab <= 0,
when: () => store.tab > 0,
onSelect: back,
},
])
const minimize = () => {
if (sending()) return
setStore("editing", false)
@@ -547,25 +518,9 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Tooltip
placement="top"
value={
<>
{language.t("ui.common.back")}
<span class="opacity-60">{backShortcut()}</span>
</>
}
>
<Button
variant="neutral"
size="large"
disabled={sending()}
onClick={back}
aria-keyshortcuts={IS_MAC ? "Meta+[" : "Alt+ArrowLeft"}
>
{language.t("ui.common.back")}
</Button>
</Tooltip>
<Button variant="neutral" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "submit" : "neutral"}
@@ -575,9 +530,6 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
<span data-slot="question-submit-shortcut" aria-hidden="true" class="text-11-medium opacity-60">
{submitShortcut()}
</span>
</Button>
</div>
</>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, SessionFormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
@@ -28,7 +28,7 @@ const provider: FormCreated["data"]["form"] = {
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: SessionFormReplyInput[] = []
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
@@ -45,7 +45,7 @@ function fixture() {
}
},
},
reply: async (input: SessionFormReplyInput) => {
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
@@ -1,4 +1,4 @@
import type { FormInfo, FormOption, SessionFormReplyInput, FormStringField } from "@opencode/client/promise"
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
@@ -14,7 +14,7 @@ export function createWebSearchRequest(input: {
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: SessionFormReplyInput) => Promise<unknown>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
@@ -93,7 +93,7 @@ export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: SessionFormReplyInput) => Promise<unknown>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
@@ -80,13 +80,13 @@ export function createTimelineController(input: { session: TimelineSessionSource
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? data.session.message.list(id) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
@@ -100,7 +100,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
parentID: input.session.data.parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("session.tab.session"),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
@@ -149,7 +149,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await serverSDK.api.session
.update({ sessionID: id, title: next })
.rename({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
@@ -209,7 +209,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const confirm = async () => {
await remove(props.sessionID)
+43 -33
View File
@@ -1,21 +1,18 @@
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode/ui/button"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import fuzzysort from "fuzzysort"
import {
DEFAULT_PALETTE_KEYBIND,
formatKeybind,
keyFromKeyboardEvent,
parseKeybind,
useCommand,
} from "@/shell/commands/command"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon })))
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -72,6 +69,13 @@ function isModifier(key: string) {
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
}
function normalizeKey(key: string) {
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
return key.toLowerCase()
}
function recordKeybind(event: KeyboardEvent) {
if (isModifier(event.key)) return
@@ -85,7 +89,7 @@ function recordKeybind(event: KeyboardEvent) {
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
const key = keyFromKeyboardEvent(event)
const key = normalizeKey(event.key)
if (!key) return
parts.push(key)
@@ -418,9 +422,17 @@ function SettingsKeybindsView(props: {
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
showClearButton={!!store.filter}
onClearClick={() => setStore("filter", "")}
/>
<Show when={store.filter}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-tab-body">
@@ -432,29 +444,27 @@ function SettingsKeybindsView(props: {
<h3 class="settings-section-title">{language.t(groupKey[group])}</h3>
<SettingsList>
<For each={filtered().get(group) ?? []}>
{(id) => {
const binding = props.keybind(id)
return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<Button
type="button"
size="small"
variant={binding ? "ghost" : "ghost-muted"}
data-keybind-id={id}
data-expanded={props.active === id ? "" : undefined}
onClick={() => props.onCapture(id)}
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-keybind-button": true,
"settings-keybind-button--active": props.active === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
<Show
when={props.active === id}
fallback={binding || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</Button>
</div>
)
}}
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsList>
</div>
+2
View File
@@ -7,6 +7,7 @@ export const pageIcons = {
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
pairing: "server",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
@@ -22,6 +23,7 @@ export const pageLabels = {
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
pairing: "settings.pairing.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
@@ -0,0 +1,303 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
export function SettingsPairing() {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const queryClient = useQueryClient()
const pair = platform.pair
if (!pair) return null
const local = useQuery(() => ({
queryKey: ["pairing", "local"],
queryFn: pair.info,
}))
// Reading pending query data would suspend the entire settings surface.
const localInfo = () => (local.isSuccess ? local.data : undefined)
const localHost = createMemo(() =>
localInfo()?.urls.find((value) => {
const host = new URL(value).hostname
return (
host !== "localhost" &&
!host.endsWith(".localhost") &&
!host.startsWith("127.") &&
host !== "[::1]" &&
host !== "0.0.0.0" &&
host !== "[::]"
)
}),
)
const screenActive = useQuery(() => ({
queryKey: ["pairing", "screen-active"],
queryFn: () => platform.getKeepScreenActive!(),
enabled: !!platform.getKeepScreenActive,
}))
const screenActivity = useMutation(() => ({
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
}))
const tailscale = useQuery(() => ({
queryKey: ["pairing", "tailscale-available"],
queryFn: pair.tailscaleAvailable,
}))
const tailscaleStatus = useQuery(() => ({
queryKey: ["pairing", "tailscale-status"],
queryFn: pair.tailscaleStatus,
enabled: tailscale.isSuccess && tailscale.data === true,
}))
const tailscaleServe = useMutation(() => ({
mutationFn: pair.openTailscale,
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
}))
const tailscaleDisable = useMutation(() => ({
mutationFn: pair.disableTailscale,
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
}))
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
<SettingsList>
<SettingsRow
title={language.t("settings.pairing.connection")}
description={language.t("pair.local.description")}
>
<Button
variant="neutral"
disabled={!localHost()}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()}
/>
))
}
>
{language.t("pair.local.open")}
</Button>
</SettingsRow>
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
<div data-action="settings-keep-screen-active">
<SettingsRow
title={language.t("pair.screenActive.title")}
description={language.t("pair.screenActive.description")}
>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{language.t("pair.screenActive.title")}
</Switch>
</SettingsRow>
</div>
</Show>
</SettingsList>
<Show when={screenActive.error || screenActivity.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.screenActive.error")}
</p>
</Show>
<Show when={local.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
</section>
<Show when={tailscale.isSuccess && tailscale.data === true}>
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
<Show
when={tailscaleInfo()}
fallback={
<>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
<Button
variant="neutral"
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
aria-busy={tailscaleServe.isPending}
onClick={() => tailscaleServe.mutate()}
>
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<g opacity="0.3">
<circle cx="3" cy="3" r="3" />
<circle cx="12" cy="3" r="3" />
<circle cx="21" cy="3" r="3" />
<circle cx="3" cy="21" r="3" />
<circle cx="21" cy="21" r="3" />
</g>
<circle cx="3" cy="12" r="3" />
<circle cx="12" cy="12" r="3" />
<circle cx="21" cy="12" r="3" />
<circle cx="12" cy="21" r="3" />
</svg>
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
</Button>
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
{language.t("pair.tailscale.description")}
</span>
</div>
</SettingsList>
</>
}
>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<SettingsRow
title={language.t("pair.tailscale.serve")}
description={language.t("pair.tailscale.description")}
>
<div class="flex flex-col items-end gap-2">
<Button
variant="neutral"
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("pair.tailscale.title")}
info={tailscaleInfo()}
host={tailscaleInfo()?.urls[0]}
/>
))
}
>
{language.t("pair.qr.open")}
</Button>
<Button
variant="neutral"
disabled={
!localInfo() ||
tailscaleStatus.isPending ||
tailscaleServe.isPending ||
tailscaleDisable.isPending
}
onClick={() => tailscaleDisable.mutate()}
>
{language.t("pair.tailscale.disable")}
</Button>
</div>
</SettingsRow>
</SettingsList>
</Show>
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.tailscale.error")}
</p>
</Show>
</section>
</Show>
</div>
</>
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
const language = useLanguage()
const platform = usePlatform()
const url = createMemo(() => {
if (!props.info) return
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
return pairingUrl(
{
urls: host ? [host] : props.info.urls.slice(0, 1),
username: props.info.username,
password: props.info.password,
},
host,
)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
const value = url()
if (!value) return
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
},
}))
createEffect(() => {
if (!copy.isSuccess) return
const timeout = setTimeout(() => copy.reset(), 2000)
onCleanup(() => clearTimeout(timeout))
})
const qr = createMemo(() => {
const value = url()
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
})
return (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
aria-label={language.t("pair.qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 justify-center pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={language.t("pair.copy")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
</p>
</Show>
</DialogBody>
</Dialog>
)
}
@@ -20,6 +20,20 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{
tab: "pairing",
label: "settings.pairing.title",
keywords: "pair device qr tailscale",
available: "desktop",
},
{
tab: "pairing",
label: "pair.screenActive.title",
description: "pair.screenActive.description",
target: "settings-keep-screen-active",
keywords: "display sleep awake local",
available: "desktop",
},
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
@@ -5,9 +5,17 @@ import type { WslServerItem } from "@/servers/wsl/types"
import { useSsh } from "@/servers/ssh/context"
import { sshName, type SshItem } from "@/servers/ssh/types"
import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: ServerCtx) {
return context.projects.list()
const tracked = context.projects.list()
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
return [
...tracked,
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
]
}
export type SettingsServer = {
+37
View File
@@ -960,6 +960,43 @@
font-variation-settings: "slnt" 0;
}
.settings-keybind-button {
box-sizing: border-box;
flex-shrink: 0;
padding: 6px 8px;
margin: -6px -8px;
border: 0;
border-radius: 2px;
background: transparent;
cursor: pointer;
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 1;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
font-feature-settings:
"tnum" on,
"lnum" on;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-faint);
}
.settings-keybind-button:hover {
background-color: var(--v2-background-bg-layer-02);
}
.settings-keybind-button:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.settings-keybind-button--active {
color: var(--v2-text-text-faint);
border-radius: 2px;
background-color: var(--v2-background-bg-layer-02);
}
.settings-shortcuts-status {
display: flex;
flex-direction: column;
+12 -1
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
@@ -18,6 +19,7 @@ import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsPairing } from "./pairing/pairing"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
@@ -41,6 +43,7 @@ const rootClientTabs = [
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
] as const
const serverTabs = [
@@ -187,6 +190,7 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
@@ -216,7 +220,11 @@ function RootSettings() {
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
{
items: rootClientTabs
.filter((item) => item.value !== "pairing" || !!platform.pair)
.map((item) => ({ ...item, label: language.t(item.label) })),
},
...(multiple()
? [
{
@@ -282,6 +290,9 @@ function RootSettings() {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="pairing" class="settings-panel">
<SettingsPairing />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
+2
View File
@@ -11,6 +11,7 @@ export type SettingsRootTab =
| "appearance"
| "notifications"
| "shortcuts"
| "pairing"
| "projects"
| "workspaces"
| "providers"
@@ -44,6 +45,7 @@ const rootTabs: Record<SettingsRootTab, true> = {
appearance: true,
notifications: true,
shortcuts: true,
pairing: true,
projects: true,
workspaces: true,
providers: true,
+4 -25
View File
@@ -24,7 +24,7 @@
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
margin-top: 16px;
margin-top: 24px;
margin-inline: 8px;
}
@@ -58,10 +58,6 @@
border-radius: 50%;
background: var(--timeline-detail-marker-background);
&[data-selected] {
background: var(--v2-background-bg-accent);
}
&:dir(rtl) {
transform: translate(50%, -50%);
}
@@ -78,6 +74,7 @@
margin: 0;
margin-inline: -8px;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:focus-visible {
@@ -130,7 +127,7 @@
}
[data-slot="timeline-detail-advanced"] {
margin-top: 0;
margin-top: 20px;
padding-top: 8px;
}
@@ -236,7 +233,7 @@
[data-slot="timeline-detail-activity"] {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
min-width: 0;
> label {
@@ -260,24 +257,6 @@
}
}
@container timeline-detail (min-width: 311px) {
[data-component="timeline-detail-control"]
[data-slot="timeline-detail-advanced"]:has(> [data-slot="collapsible-trigger"][aria-expanded="true"]) {
> [data-slot="collapsible-trigger"] {
position: relative;
z-index: 1;
}
> [data-slot="collapsible-content"] {
margin-top: -24px;
}
[data-slot="timeline-detail-columns"] {
min-height: 24px;
}
}
}
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
--timeline-detail-marker-background:
@@ -51,12 +51,7 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
>
<For each={presets}>
{(_, index) => (
<span
data-selected={index() <= position() ? "" : undefined}
style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }}
/>
)}
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
</For>
</div>
<input
+20 -123
View File
@@ -1,25 +1,14 @@
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { usePlatform } from "@/runtime/platform/platform"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { fileManagerApp } from "@/home/projects/file-manager"
import { addProjects } from "@/home/projects/add"
import { revealProject } from "@/home/projects/reveal"
import { showToast } from "@/shell/notifications/toast"
import { settingsProjects } from "../servers/inventory"
import { SettingsList } from "../list"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
@@ -30,13 +19,9 @@ export const SettingsProjects: Component<{
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const platform = usePlatform()
const tabs = useTabs()
const pickDirectory = useDirectoryPicker()
const [store, setStore] = createStore({ filter: "", menu: undefined as string | undefined })
const [store, setStore] = createStore({ filter: "" })
let search: HTMLInputElement | undefined
const context = createMemo(() => global.ensureServerCtx(props.server))
const projects = createMemo(() => settingsProjects(context()))
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
@@ -58,54 +43,6 @@ export const SettingsProjects: Component<{
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
const addProject = () =>
pickDirectory({
server: props.server,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => addProjects(context(), homeProjectDirectories(result)),
})
const newSession = (project: LocalProject) => {
context().projects.open(project.worktree)
context().projects.touch(project.worktree)
void tabs.newDraft({ server: ServerConnection.key(props.server), directory: project.worktree })
}
const canReveal = () =>
platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(props.server)
const reveal = (project: LocalProject) => {
if (!platform.revealPath || !canReveal()) return
void revealProject({
directory: project.worktree,
reveal: platform.revealPath,
remove: context().projects.remove,
})
.then((revealed) => {
if (revealed) return
showToast({
variant: "error",
title: language.t("home.project.missing.title"),
description: language.t("home.project.missing.description", { name: displayName(project) }),
})
})
.catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
const unseen = (project: LocalProject) =>
[project.worktree, ...(project.sandboxes ?? [])].reduce(
(total, directory) => total + context().notification.project.unseenCount(directory),
0,
)
const clearNotifications = (project: LocalProject) => {
const notification = context().notification
const directories = [project.worktree, ...(project.sandboxes ?? [])]
directories
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
}
return (
<>
@@ -115,9 +52,6 @@ export const SettingsProjects: Component<{
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Button variant="ghost-muted" icon="plus" onClick={addProject}>
{language.t("home.project.add")}
</Button>
</div>
<Show when={searchable()}>
<div class="settings-tab-search">
@@ -152,65 +86,28 @@ export const SettingsProjects: Component<{
</div>
}
>
<SettingsList variant="catalog">
<div class="flex w-full flex-col gap-2">
<For each={filtered()}>
{(project) => (
<div data-component="settings-row" class="group/project relative !gap-2">
<button
type="button"
aria-label={displayName(project)}
class="group/target -my-4 flex min-h-[52px] min-w-0 flex-1 items-center gap-2 rounded-[4px] bg-transparent py-4 text-start focus-visible:outline-none focus-visible:[box-shadow:inset_0_0_0_1px_var(--v2-border-border-focus)]"
onClick={() => props.onOpenProject(project)}
>
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<span class="flex min-w-0 items-center gap-1">
<bdi class="truncate text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base">
{displayName(project)}
</bdi>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-visible/target:opacity-100 rtl:rotate-180"
/>
</span>
</button>
<Menu
gutter={4}
modal={false}
placement="bottom-end"
open={store.menu === project.worktree}
onOpenChange={(open) => setStore("menu", open ? project.worktree : undefined)}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
class="-my-0.5"
icon={<Icon name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={() => newSession(project)}>{language.t("command.session.new")}</Menu.Item>
<Show when={canReveal()}>
<Menu.Item onSelect={() => reveal(project)}>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Item disabled={unseen(project) === 0} onSelect={() => clearNotifications(project)}>
{language.t("sidebar.project.clearNotifications")}
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={() => context().projects.close(project.worktree)}>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</For>
</SettingsList>
</div>
</Show>
</div>
</>
@@ -51,15 +51,6 @@ describe("command keybind helpers", () => {
).toBe(true)
})
test("matchKeybind uses the letter key for option-modified characters", () => {
expect(
matchKeybind(
parseKeybind("meta+alt+l"),
new KeyboardEvent("keydown", { key: "¬", code: "KeyL", metaKey: true, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")
+2 -9
View File
@@ -50,20 +50,13 @@ function normalizeKey(key: string) {
return key.toLowerCase()
}
export function keyFromKeyboardEvent(event: KeyboardEvent) {
const key = normalizeKey(event.key)
if (!event.altKey || /^[a-z0-9]$/.test(key)) return key
if (!event.code.startsWith("Key") || event.code.length !== 4) return key
return event.code.slice(3).toLowerCase()
}
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
return `${key}:${mask}`
}
function signatureFromEvent(event: KeyboardEvent) {
return signature(keyFromKeyboardEvent(event), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
function isAllowedEditableKeybind(id: string | undefined) {
@@ -186,7 +179,7 @@ export function parseKeybind(config: string): Keybind[] {
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = keyFromKeyboardEvent(event)
const eventKey = normalizeKey(event.key)
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
@@ -4,6 +4,7 @@ import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
import { useUpdaterAction } from "@/shell/updates/action"
import { useSettingsSurface } from "@/settings/surface"
export function DesktopCommands() {
const command = useCommand()
@@ -40,3 +41,25 @@ export function DesktopCommands() {
return null
}
export function DesktopPairingCommand() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettingsSurface()
command.register("desktop-pairing", () =>
platform.platform === "desktop" && platform.pair
? [
{
id: "server.pair",
title: language.t("command.server.pair"),
category: language.t("command.category.server"),
onSelect: () => settings.open("pairing"),
},
]
: [],
)
return null
}
+48 -25
View File
@@ -1,5 +1,5 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,6 +10,8 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
@@ -26,6 +28,7 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -33,29 +36,48 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
onMount(() => {
if (!pairing) return
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
navigate("/", { replace: true })
})
return (
<Show when={!pairing}>
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
</Show>
)
}
@@ -79,6 +101,7 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
@@ -10,6 +10,10 @@ test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
test("connect has its own layout route", () => {
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
+2
View File
@@ -67,6 +67,7 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "connect" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -106,6 +107,7 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "connect") return { type: "connect" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
@@ -116,7 +116,7 @@ function SessionTabEntry(props: {
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.update({ sessionID: value.id, title })
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
@@ -303,6 +303,7 @@ export function Titlebar(props: {
return
}
case "settings":
case "connect":
case "home": {
const selection = layout.home.selection()
const conn =
+21 -18
View File
@@ -4,31 +4,34 @@ import { brotliCompressSync, constants } from "node:zlib"
import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return "{}"
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
return JSON.stringify(
Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
// Independent entries let the server materialize only assets the browser requests.
return [key, compress(body)] as const
}),
),
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
),
)
return compress(assets)
}
function compress(body: Buffer) {
return brotliCompressSync(body, {
params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
function compress(assets: object) {
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
+1 -2
View File
@@ -74,7 +74,7 @@ const appAssetsPlugin: BunPlugin = {
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${appArchive}`,
contents: `export default ${JSON.stringify(appArchive)}`,
}))
},
}
@@ -140,7 +140,6 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [
"--smol",
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
+2 -1
View File
@@ -33,7 +33,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -54,6 +54,7 @@ try {
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
+1 -1
View File
@@ -165,7 +165,7 @@ export async function streamTurn(input: {
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.session.form
await input.client.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
+1 -1
View File
@@ -55,7 +55,7 @@ export async function replyPermission(input: {
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
decision: reply,
reply,
})
}
+18 -21
View File
@@ -1,51 +1,48 @@
import { Effect, FileSystem, Option } from "effect"
import { readFileSync } from "node:fs"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, string>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && (Object.keys(embedded.value.default).length > 0 || !OPENCODE_LOCAL))
return lazy(embedded.value.default, (key) =>
brotliDecompressSync(Buffer.from(embedded.value.default[key]!, "base64")),
)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return yield* sourceAssets()
return decode(yield* sourceAssets())
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
const assets = Object.fromEntries(
return Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
return [file, target] as const
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
return lazy(assets, (key) => readFileSync(assets[key]!))
})
function lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap {
// Immutable browser caching makes retaining decompressed copies in the server unnecessary.
return new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string" || assets[key] === undefined) return
const body = load(key)
return isText(key) ? Buffer.from(body).toString() : body
},
},
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
)
}
+4 -4
View File
@@ -145,7 +145,7 @@ export async function runNonInteractivePrompt(input: Input) {
.reply({
sessionID: input.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
reply: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
@@ -155,7 +155,7 @@ export async function runNonInteractivePrompt(input: Input) {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.session.form.cancel(
await input.client.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
@@ -695,10 +695,10 @@ export async function runNonInteractivePrompt(input: Input) {
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.session.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form
: input.client.form.request
.list({
location: { directory: input.location.directory },
})
+1 -1
View File
@@ -128,7 +128,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
if (!target.resume && input.title !== undefined) {
await client.session.update({
await client.session.rename({
sessionID: target.session.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
+14 -17
View File
@@ -10,28 +10,25 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
})
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
const requested = assets[key]
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && requested === undefined)
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && assets[key] === undefined)
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
const name = requested !== undefined ? key : "index.html"
const file = requested ?? assets["index.html"]
if (file === undefined) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
+1 -1
View File
@@ -1,4 +1,4 @@
declare module "virtual:opencode-app-assets" {
const archive: Readonly<Record<string, string>>
const archive: string
export default archive
}
+1 -3
View File
@@ -748,9 +748,7 @@ describe("acp event behavior", () => {
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some(
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
),
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
).toBe(true)
} finally {
await fixture.stop()
@@ -574,7 +574,7 @@ function permissionReplies(fixture: Fixture) {
return fixture.requests.flatMap((request): Array<[string, string]> => {
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
const reply = "decision" in request.body ? request.body.decision : undefined
const reply = "reply" in request.body ? request.body.reply : undefined
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
+3 -3
View File
@@ -128,7 +128,7 @@ export function createSseFixture(options: FixtureOptions = {}) {
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
if (permission?.[1] && permission[2]) {
const reply = stringField(body, "decision")
const reply = stringField(body, "reply")
if (!reply) return new Response(null, { status: 400 })
await options.onPermissionReply?.({
sessionID: decodeURIComponent(permission[1]),
@@ -140,8 +140,8 @@ export function createSseFixture(options: FixtureOptions = {}) {
return new Response(null, { status: 204 })
}
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)$/.exec(url.pathname)
if (request.method === "DELETE" && form?.[1] && form[2]) {
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
if (form?.[1] && form[2]) {
await options.onFormCancel?.({
sessionID: decodeURIComponent(form[1]),
formID: decodeURIComponent(form[2]),
+11 -11
View File
@@ -240,17 +240,17 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.session.form, "list").mockImplementation(
spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form, "list").mockImplementation(
spyOn(sdk.form.request, "list").mockImplementation(
() =>
ok({
location: { ...location, project: { id: "proj_1", directory: location.directory } },
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
}) as never,
)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -426,10 +426,10 @@ describe("runNonInteractivePrompt", () => {
"x-opencode-directory": "%2Fwork%20tree",
},
}
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.list).toHaveBeenCalledWith({
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
})
@@ -440,10 +440,10 @@ describe("runNonInteractivePrompt", () => {
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.request.list).not.toHaveBeenCalled()
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
+59
View File
@@ -1,4 +1,5 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { ServerProcess } from "@opencode/server/process"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -7,11 +8,69 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
import { it } from "../../core/test/lib/effect"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
it.live("serves the web shell and assets before server authentication", () =>
Effect.gen(function* () {
const transform = yield* WebUi.handler({
assets: {
"index.html": "<html><body>connect</body></html>",
"_assets/app.js": "console.log('connect')",
"_assets/app.css": "body { color: black; }",
"icons/icon.svg": "<svg></svg>",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
"sw.js": "service worker",
},
})
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
transform,
)
const origin = HttpServer.formatAddress(server.address)
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
"/icons/icon.svg",
"/font.woff2",
"/sw.js",
],
(pathname) =>
Effect.gen(function* () {
yield* Effect.forEach(["GET", "HEAD"], (method) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
yield* Effect.promise(() => response.arrayBuffer())
}),
)
}),
)
yield* Effect.forEach(["/api", "/api/status", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
yield* Effect.promise(() => response.arrayBuffer())
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/status", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
+2 -2
View File
@@ -31,7 +31,7 @@ function appAssetsPlugin(archive: string): Plugin {
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${archive}`
return `export default ${JSON.stringify(archive)}`
},
}
}
@@ -292,5 +292,5 @@ export default mainConfig({
channel: process.env.OPENCODE_CHANNEL ?? "local",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "{}",
appArchive: "",
})
+82 -74
View File
@@ -23,8 +23,8 @@ import type { Event } from "@opencode/schema/event"
import type { EventLog } from "@opencode/schema/event-log"
import type { Shell } from "@opencode/schema/shell"
import type { Provider } from "@opencode/schema/provider"
import type { Form } from "@opencode/schema/form"
import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
@@ -233,13 +233,9 @@ export type SessionSwitchModelOperation<E = never> = (
input: SessionSwitchModelInput,
) => Effect.Effect<SessionSwitchModelOutput, E>
export type SessionUpdateInput = {
readonly sessionID: Session.ID
readonly title?: string | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionUpdateOutput = void
export type SessionUpdateOperation<E = never> = (input: SessionUpdateInput) => Effect.Effect<SessionUpdateOutput, E>
export type SessionRenameInput = { readonly sessionID: Session.ID; readonly title: string }
export type SessionRenameOutput = void
export type SessionRenameOperation<E = never> = (input: SessionRenameInput) => Effect.Effect<SessionRenameOutput, E>
export type SessionMoveInput = {
readonly sessionID: Session.ID
@@ -364,15 +360,17 @@ export type SessionInboxCancelOperation<E = never> = (
input: SessionInboxCancelInput,
) => Effect.Effect<SessionInboxCancelOutput, E>
export type SessionInboxUpdateInput = {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
export type SessionInboxUpdateOutput = void
export type SessionInboxUpdateOperation<E = never> = (
input: SessionInboxUpdateInput,
) => Effect.Effect<SessionInboxUpdateOutput, E>
export type SessionInboxSteerInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxSteerOutput = void
export type SessionInboxSteerOperation<E = never> = (
input: SessionInboxSteerInput,
) => Effect.Effect<SessionInboxSteerOutput, E>
export type SessionInboxQueueInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxQueueOutput = void
export type SessionInboxQueueOperation<E = never> = (
input: SessionInboxQueueInput,
) => Effect.Effect<SessionInboxQueueOutput, E>
export type SessionInstructionsEntryListInput = { readonly sessionID: Session.ID }
export type SessionInstructionsEntryListOutput = ReadonlyArray<InstructionEntry.Info>
@@ -515,7 +513,7 @@ export type SessionLogOutput =
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions"
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?:
| {
@@ -1327,44 +1325,6 @@ export type SessionMessageGetOperation<E = never> = (
input: SessionMessageGetInput,
) => Effect.Effect<SessionMessageGetOutput, E>
export type SessionFormListInput = { readonly sessionID: string }
export type SessionFormListOutput = ReadonlyArray<Form.Info>
export type SessionFormListOperation<E = never> = (
input: SessionFormListInput,
) => Effect.Effect<SessionFormListOutput, E>
export type SessionFormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type SessionFormCreateOutput = Form.Info
export type SessionFormCreateOperation<E = never> = (
input: SessionFormCreateInput,
) => Effect.Effect<SessionFormCreateOutput, E>
export type SessionFormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormGetOutput = Form.Detail
export type SessionFormGetOperation<E = never> = (input: SessionFormGetInput) => Effect.Effect<SessionFormGetOutput, E>
export type SessionFormReplyInput = {
readonly sessionID: string
readonly formID: Form.ID
readonly answer: Form.Answer
}
export type SessionFormReplyOutput = void
export type SessionFormReplyOperation<E = never> = (
input: SessionFormReplyInput,
) => Effect.Effect<SessionFormReplyOutput, E>
export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormCancelOutput = void
export type SessionFormCancelOperation<E = never> = (
input: SessionFormCancelInput,
) => Effect.Effect<SessionFormCancelOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1390,7 +1350,7 @@ export interface SessionApi<E = never> {
readonly fork: SessionForkOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly update: SessionUpdateOperation<E>
readonly rename: SessionRenameOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E>
@@ -1409,7 +1369,8 @@ export interface SessionApi<E = never> {
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly update: SessionInboxUpdateOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
}
readonly instructions: {
readonly entry: {
@@ -1423,13 +1384,6 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: { readonly get: SessionMessageGetOperation<E> }
readonly form: {
readonly list: SessionFormListOperation<E>
readonly create: SessionFormCreateOperation<E>
readonly get: SessionFormGetOperation<E>
readonly reply: SessionFormReplyOperation<E>
readonly cancel: SessionFormCancelOperation<E>
}
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -1730,12 +1684,50 @@ export interface ProjectApi<E = never> {
readonly update: ProjectUpdateOperation<E>
}
export type FormListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormListOperation<E = never> = (input?: FormListInput) => Effect.Effect<FormListOutput, E>
export type FormRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormRequestListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (
input?: FormRequestListInput,
) => Effect.Effect<FormRequestListOutput, E>
export type FormListInput = { readonly sessionID: string }
export type FormListOutput = ReadonlyArray<Form.Info>
export type FormListOperation<E = never> = (input: FormListInput) => Effect.Effect<FormListOutput, E>
export type FormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type FormCreateOutput = Form.Info
export type FormCreateOperation<E = never> = (input: FormCreateInput) => Effect.Effect<FormCreateOutput, E>
export type FormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormGetOutput = Form.Info
export type FormGetOperation<E = never> = (input: FormGetInput) => Effect.Effect<FormGetOutput, E>
export type FormStateInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormStateOutput = Form.State
export type FormStateOperation<E = never> = (input: FormStateInput) => Effect.Effect<FormStateOutput, E>
export type FormReplyInput = { readonly sessionID: string; readonly formID: Form.ID; readonly answer: Form.Answer }
export type FormReplyOutput = void
export type FormReplyOperation<E = never> = (input: FormReplyInput) => Effect.Effect<FormReplyOutput, E>
export type FormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormCancelOutput = void
export type FormCancelOperation<E = never> = (input: FormCancelInput) => Effect.Effect<FormCancelOutput, E>
export interface FormApi<E = never> {
readonly request: { readonly list: FormRequestListOperation<E> }
readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<E>
readonly state: FormStateOperation<E>
readonly reply: FormReplyOperation<E>
readonly cancel: FormCancelOperation<E>
}
export type PermissionRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -1785,7 +1777,7 @@ export type PermissionGetOperation<E = never> = (input: PermissionGetInput) => E
export type PermissionReplyInput = {
readonly sessionID: Session.ID
readonly requestID: Permission.ID
readonly decision: Permission.Reply
readonly reply: Permission.Reply
readonly message?: string | undefined
}
export type PermissionReplyOutput = void
@@ -1793,6 +1785,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1800,6 +1798,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -2102,7 +2101,7 @@ export type ShellCreateInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout?: number | undefined
readonly timeout: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
}
export type ShellCreateOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
@@ -2115,6 +2114,14 @@ export type ShellGetInput = {
export type ShellGetOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: ShellGetInput) => Effect.Effect<ShellGetOutput, E>
export type ShellTimeoutInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly timeout: number
}
export type ShellTimeoutOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: ShellTimeoutInput) => Effect.Effect<ShellTimeoutOutput, E>
export type ShellOutputInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -2143,6 +2150,7 @@ export interface ShellApi<E = never> {
readonly list: ShellListOperation<E>
readonly create: ShellCreateOperation<E>
readonly get: ShellGetOperation<E>
readonly timeout: ShellTimeoutOperation<E>
readonly output: ShellOutputOperation<E>
readonly remove: ShellRemoveOperation<E>
}
@@ -2205,13 +2213,13 @@ export type VcsStatusInput = { readonly location?: { readonly directory?: string
export type VcsStatusOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
export type VcsBranchListInput = {
export type VcsBranchesInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}
export type VcsBranchListOutput = { readonly location: Location.PublicRef; readonly data: Vcs.BranchList }
export type VcsBranchListOperation<E = never> = (input?: VcsBranchListInput) => Effect.Effect<VcsBranchListOutput, E>
export type VcsBranchesOutput = { readonly location: Location.PublicRef; readonly data: Vcs.BranchList }
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -2226,7 +2234,7 @@ export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
readonly branch: { readonly list: VcsBranchListOperation<E> }
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
}
+130 -88
View File
@@ -39,8 +39,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionUpdateInput,
SessionUpdateOutput,
SessionRenameInput,
SessionRenameOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -71,8 +71,10 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -89,16 +91,6 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -158,8 +150,20 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -174,6 +178,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -221,6 +227,8 @@ import type {
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
@@ -241,8 +249,8 @@ import type {
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchListInput,
VcsBranchListOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -442,12 +450,11 @@ const EndpointSessionSwitchModel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: SessionUpdateInput) =>
preserveEffect<SessionUpdateOutput>()(
raw["session.update"]({
params: { sessionID: input["sessionID"] },
payload: { title: input["title"], permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
const EndpointSessionRename = (raw: RawClient["server.session"]) => (input: SessionRenameInput) =>
preserveEffect<SessionRenameOutput>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionMove = (raw: RawClient["server.session"]) => (input: SessionMoveInput) =>
@@ -598,12 +605,18 @@ const EndpointSessionInboxCancel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionInboxUpdate = (raw: RawClient["server.session"]) => (input: SessionInboxUpdateInput) =>
preserveEffect<SessionInboxUpdateOutput>()(
raw["session.inbox.update"]({
params: { sessionID: input["sessionID"], inboxID: input["inboxID"] },
payload: { delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
const EndpointSessionInboxSteer = (raw: RawClient["server.session"]) => (input: SessionInboxSteerInput) =>
preserveEffect<SessionInboxSteerOutput>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionInboxQueue = (raw: RawClient["server.session"]) => (input: SessionInboxQueueInput) =>
preserveEffect<SessionInboxQueueOutput>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionInstructionsEntryList =
@@ -673,48 +686,6 @@ const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionFormList = (raw: RawClient["server.session"]) => (input: SessionFormListInput) =>
preserveEffect<SessionFormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormCreate = (raw: RawClient["server.session"]) => (input: SessionFormCreateInput) =>
preserveEffect<SessionFormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormGet = (raw: RawClient["server.session"]) => (input: SessionFormGetInput) =>
preserveEffect<SessionFormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: SessionFormReplyInput) =>
preserveEffect<SessionFormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
preserveEffect<SessionFormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -742,7 +713,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
fork: EndpointSessionFork(raw),
switchAgent: EndpointSessionSwitchAgent(raw),
switchModel: EndpointSessionSwitchModel(raw),
update: EndpointSessionUpdate(raw),
rename: EndpointSessionRename(raw),
move: EndpointSessionMove(raw),
prompt: EndpointSessionPrompt(raw),
command: EndpointSessionCommand(raw),
@@ -761,7 +732,8 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
update: EndpointSessionInboxUpdate(raw),
steer: EndpointSessionInboxSteer(raw),
queue: EndpointSessionInboxQueue(raw),
},
instructions: {
entry: {
@@ -775,13 +747,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: { get: EndpointSessionMessageGet(raw) },
form: {
list: EndpointSessionFormList(raw),
create: EndpointSessionFormCreate(raw),
get: EndpointSessionFormGet(raw),
reply: EndpointSessionFormReply(raw),
cancel: EndpointSessionFormCancel(raw),
},
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -1041,12 +1006,70 @@ const adaptGroupProject = (raw: RawClient["server.project"]) => ({
update: EndpointProjectUpdate(raw),
})
const EndpointFormList = (raw: RawClient["server.form"]) => (input?: FormListInput) =>
preserveEffect<FormListOutput>()(
raw["form.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
const EndpointFormRequestList = (raw: RawClient["server.form"]) => (input?: FormRequestListInput) =>
preserveEffect<FormRequestListOutput>()(
raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupForm = (raw: RawClient["server.form"]) => ({ list: EndpointFormList(raw) })
const EndpointFormList = (raw: RawClient["server.form"]) => (input: FormListInput) =>
preserveEffect<FormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormCreate = (raw: RawClient["server.form"]) => (input: FormCreateInput) =>
preserveEffect<FormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormGet = (raw: RawClient["server.form"]) => (input: FormGetInput) =>
preserveEffect<FormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormState = (raw: RawClient["server.form"]) => (input: FormStateInput) =>
preserveEffect<FormStateOutput>()(
raw["session.form.state"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormReply = (raw: RawClient["server.form"]) => (input: FormReplyInput) =>
preserveEffect<FormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormCancel = (raw: RawClient["server.form"]) => (input: FormCancelInput) =>
preserveEffect<FormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupForm = (raw: RawClient["server.form"]) => ({
request: { list: EndpointFormRequestList(raw) },
list: EndpointFormList(raw),
create: EndpointFormCreate(raw),
get: EndpointFormGet(raw),
state: EndpointFormState(raw),
reply: EndpointFormReply(raw),
cancel: EndpointFormCancel(raw),
})
const EndpointPermissionRequestList = (raw: RawClient["server.permission"]) => (input?: PermissionRequestListInput) =>
preserveEffect<PermissionRequestListOutput>()(
@@ -1105,7 +1128,15 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
preserveEffect<PermissionReplyOutput>()(
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { decision: input["decision"], message: input["message"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1116,6 +1147,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -1361,6 +1393,15 @@ const EndpointShellGet = (raw: RawClient["server.shell"]) => (input: ShellGetInp
),
)
const EndpointShellTimeout = (raw: RawClient["server.shell"]) => (input: ShellTimeoutInput) =>
preserveEffect<ShellTimeoutOutput>()(
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
payload: { timeout: input["timeout"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointShellOutput = (raw: RawClient["server.shell"]) => (input: ShellOutputInput) =>
preserveEffect<ShellOutputOutput>()(
raw["shell.output"]({
@@ -1380,6 +1421,7 @@ const adaptGroupShell = (raw: RawClient["server.shell"]) => ({
list: EndpointShellList(raw),
create: EndpointShellCreate(raw),
get: EndpointShellGet(raw),
timeout: EndpointShellTimeout(raw),
output: EndpointShellOutput(raw),
remove: EndpointShellRemove(raw),
})
@@ -1443,9 +1485,9 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBranchList = (raw: RawClient["server.vcs"]) => (input?: VcsBranchListInput) =>
preserveEffect<VcsBranchListOutput>()(
raw["vcs.branch.list"]({
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
preserveEffect<VcsBranchesOutput>()(
raw["vcs.branches"]({
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1461,7 +1503,7 @@ const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
branch: { list: EndpointVcsBranchList(raw) },
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
})
+159 -107
View File
@@ -33,8 +33,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionUpdateInput,
SessionUpdateOutput,
SessionRenameInput,
SessionRenameOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -65,8 +65,10 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -83,16 +85,6 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -152,8 +144,20 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -168,6 +172,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -217,6 +223,8 @@ import type {
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
@@ -237,8 +245,8 @@ import type {
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchListInput,
VcsBranchListOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -636,12 +644,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
update: (input: SessionUpdateInput, requestOptions?: RequestOptions) =>
request<SessionUpdateOutput>(
rename: (input: SessionRenameInput, requestOptions?: RequestOptions) =>
request<SessionRenameOutput>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
body: { title: input["title"], permissions: input["permissions"] },
body: { title: input["title"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -843,17 +851,27 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
update: (input: SessionInboxUpdateInput, requestOptions?: RequestOptions) =>
request<SessionInboxUpdateOutput>(
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
body: { delivery: input["delivery"] },
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
@@ -959,70 +977,11 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
},
form: {
list: (input: SessionFormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: SessionFormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionFormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: SessionFormReplyInput, requestOptions?: RequestOptions) =>
request<SessionFormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: SessionFormCancelInput, requestOptions?: RequestOptions) =>
request<SessionFormCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`,
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/environment`,
body: { variables: input["variables"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1088,7 +1047,7 @@ export function make(options: ClientOptions) {
request<{ readonly data: GenerateTextOutput }>(
{
method: "POST",
path: `/api/experimental/generate`,
path: `/api/generate`,
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
@@ -1408,17 +1367,87 @@ export function make(options: ClientOptions) {
),
},
form: {
list: (input?: FormListInput, requestOptions?: RequestOptions) =>
request<FormListOutput>(
request: {
list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
request<FormRequestListOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>(
{
method: "GET",
path: `/api/form`,
query: { location: input?.["location"] },
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: FormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
state: (input: FormStateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormStateOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
request<FormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
request<FormCancelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
permission: {
@@ -1508,7 +1537,19 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { decision: input["decision"], message: input["message"] },
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -1858,6 +1899,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
timeout: (input: ShellTimeoutInput, requestOptions?: RequestOptions) =>
request<ShellTimeoutOutput>(
{
method: "PATCH",
path: `/api/shell/${encodeURIComponent(input.id)}/timeout`,
query: { location: input["location"] },
body: { timeout: input["timeout"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
output: (input: ShellOutputInput, requestOptions?: RequestOptions) =>
request<ShellOutputOutput>(
{
@@ -1877,7 +1931,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1990,20 +2044,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
branch: {
list: (input?: VcsBranchListInput, requestOptions?: RequestOptions) =>
request<VcsBranchListOutput>(
{
method: "GET",
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
request<VcsBranchesOutput>(
{
method: "GET",
path: `/api/vcs/branches`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -7,9 +7,9 @@ import type {
AgentInfo,
CommandInfo,
ConfigEntry,
SessionFormCancelInput,
FormCancelInput,
FormInfo,
SessionFormReplyInput,
FormReplyInput,
IntegrationInfo,
LocationRef,
LocationGetOutput,
@@ -296,7 +296,7 @@ export function createData(config: CreateDataInput) {
return true
}
function settleForm(input: SessionFormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
return request
.catch((error: unknown) => {
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
@@ -697,7 +697,7 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions":
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
@@ -1742,7 +1742,7 @@ export function createData(config: CreateDataInput) {
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`
return sync.run(key, async () => {
if (sessionID === "global") {
const response = await api().form.list({
const response = await api().form.request.list({
location: locationQuery(ref ?? defaultLocation()),
})
const location = {
@@ -1757,7 +1757,7 @@ export function createData(config: CreateDataInput) {
])
return
}
setStore("session", "form", sessionID, await api().session.form.list({ sessionID }))
setStore("session", "form", sessionID, await api().form.list({ sessionID }))
})
},
invalidate(sessionID: string, ref?: LocationRef) {
@@ -1765,11 +1765,11 @@ export function createData(config: CreateDataInput) {
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
)
},
reply(input: SessionFormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.reply(input, formRequestOptions(input.sessionID, ref)))
reply(input: FormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: SessionFormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.cancel(input, formRequestOptions(input.sessionID, ref)))
cancel(input: FormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
},
+5 -5
View File
@@ -157,7 +157,7 @@ test("generate.text uses the locationless public contract", async () => {
})
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
expect(request?.url).toBe("http://localhost:3000/api/experimental/generate")
expect(request?.url).toBe("http://localhost:3000/api/generate")
expect(await request?.json()).toEqual({ prompt: "ping" })
})
@@ -546,13 +546,13 @@ test("session.inbox mutations use the public HTTP contract", async () => {
})
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_steer", delivery: "steer" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_queue", delivery: "queue" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
])
})
+33 -31
View File
@@ -19,16 +19,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
what `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish,
`undefined` array elements and non-finite numbers become `null`, a cyclic value throws the same `TypeError`,
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments follow JSON serialization semantics before their schema applies (see the tools section). Own
`__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results
cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute.
- [x] Values `JSON.stringify` would flatten to `{}` cross the host boundary in a useful form instead: a Set as an
array, a RegExp as `"/source/flags"`, a URLSearchParams as its query string. A Map still crosses as `{}`.
Functions, generators, promises, extension handles, and a Uint8Array are rejected with a hint. In-program
`JSON.stringify` keeps JS behavior for all of these.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
@@ -192,7 +190,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
creates a hole without changing its length. Deleting a non-configurable property (`length`, `lastIndex`) or
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
@@ -233,8 +231,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and
resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are
accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like
any function.
accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot
cross the data boundary.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
@@ -242,9 +240,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last tool supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [x] Outbound tool arguments are what `JSON.stringify` would serialize (see the boundary rule above). Tools never
receive `undefined` inside their input object, though a bare `tools.t(undefined)` argument still reaches schema
decoding as `undefined`.
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
arrays densify. Tools never receive `undefined` inside their input object, though a bare `tools.t(undefined)`
argument still reaches schema decoding as `undefined`. Program results keep the stricter
normalization where every `undefined` becomes `null`.
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
## Objects and properties
@@ -394,9 +394,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`unicodeSets`, and `dotAll`.
- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`, shared by `exec`, `test`, and the String methods. It is a prototype accessor that stores
a number, so `re.lastIndex = "12"` reads back `12`, `delete` is a no-op, and `hasOwnProperty("lastIndex")` is
`false`.
- [x] Writable `lastIndex`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
- [x] `RegExp.escape`.
@@ -428,8 +426,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Uint8Array
The only binary type. Bytes stay inside the program or cross to extensions as copies; the tool boundary rejects them
with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
The only binary type. Bytes stay inside the program or cross to extensions; the tool boundary rejects them with a
hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `new Uint8Array(length | array | iterable | Uint8Array)`, `Uint8Array.from`, `Uint8Array.of`, `fromBase64`,
and `fromHex`. Lengths are capped like arrays.
@@ -454,25 +452,29 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
## Extensions
Host functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Host classes and functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Nothing is exposed unless a host provides it; extension calls are not tool calls.
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Each global is a class or a function, exposed as-is: constructors with `new`, prototype methods, accessors,
and statics (including through an exposed subclass, so `new this()` works), plus inheritance
up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`.
- [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's.
The same host instance is always the same handle within a run, so identity and `instanceof` hold. Handles
cannot cross the data boundary: returning, stringifying, throwing, or passing one to a tool fails.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
(`fetch.json`). Like any program function it vanishes at the data boundary.
un-awaited promises, and symbols cannot be passed in; an instance of an unexposed class, a symbol, or a BigInt
cannot come out.
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
plain data). A getter must be synchronous.
- [x] A prototype member runs only with a handle of its own class as `this`; a detached call, a plain object, or a
handle of another class throws `TypeError: Illegal invocation`. Program edits to an exposed prototype affect
that run only. Data properties on a class or prototype are not exposed, since a program write would change the
host class itself; expose one through an accessor.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
## Errors and diagnostics
+1 -1
View File
@@ -37,7 +37,7 @@ export type ResolvedExecutionLimits = {
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Host functions exposed as globals; see `Extension.make`. */
/** Host classes and functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
limits?: ExecutionLimits

Some files were not shown because too many files have changed in this diff Show More