Compare commits

..
Author SHA1 Message Date
Aiden Cline a6d646b6e6 rm 2026-01-14 01:41:28 -06:00
Aiden Cline 44555cf4f4 add todo 2026-01-14 01:22:39 -06:00
Aiden Cline ca14987c8e feat: official copilot integration 2026-01-14 01:22:29 -06:00
140 changed files with 1596 additions and 4332 deletions
-24
View File
@@ -1,24 +0,0 @@
---
description: "Bump AI sdk dependencies minor / patch versions only"
---
Please read @package.json and @packages/opencode/package.json.
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
I want a report of every dependency and the version that can be upgraded to.
What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added.
Consider using subagents for each dep to save your context window.
Here is a short list of some deps (please be comprehensive tho):
- "ai"
- "@ai-sdk/openai"
- "@ai-sdk/anthropic"
- "@openrouter/ai-sdk-provider"
- etc, etc
DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only.
Write up your findings to ai-sdk-updates.md
-29
View File
@@ -1,32 +1,3 @@
# Security
## Threat Model
### Overview
OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access.
### No Sandbox
OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation.
If you need true isolation, run OpenCode inside a Docker container or VM.
### Server Mode
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
### Out of Scope
| Category | Rationale |
| ------------------------------- | ----------------------------------------------------------------------- |
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
---
# Reporting Security Issues # Reporting Security Issues
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
-1
View File
@@ -200,4 +200,3 @@
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) | | 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) | | 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
+30 -30
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -101,7 +101,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -128,7 +128,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -152,7 +152,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -176,7 +176,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -205,7 +205,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -234,7 +234,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -250,7 +250,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.21", "version": "1.1.19",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -278,7 +278,7 @@
"@ai-sdk/vercel": "1.0.31", "@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1", "@gitlab/gitlab-ai-provider": "3.1.0",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
@@ -290,8 +290,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.73", "@opentui/core": "0.1.72",
"@opentui/solid": "0.1.73", "@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -354,7 +354,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -374,7 +374,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.21", "version": "1.1.19",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -385,7 +385,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -398,7 +398,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -438,7 +438,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -449,7 +449,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -505,7 +505,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.5", "@types/bun": "1.3.4",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -913,7 +913,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="], "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-S0MVXsogrwbOboA/8L0CY5sBXg2HrrO8gdeUeHd9yLZDPsggFD0FzcSuzO5vBO6geUOpruRa8Hqrbb6WWu7Frw=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1215,21 +1215,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="], "@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
"@opentui/solid": ["@opentui/solid@0.1.73", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.73", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-FBSTiuWl+hHqFxmrJfC93cbJ0PJ4QoFbvRFuD6Gzrea5rH+G7BidjyI8YZuCcNnriDuIYaXTJdvBqe15lgKR1A=="], "@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1773,7 +1773,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -2075,7 +2075,7 @@
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1768395095, "lastModified": 1768302833,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=", "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5", "rev": "61db79b0c6b838d9894923920b612048e1201926",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -369,7 +369,7 @@ case $current_shell in
config_files="$HOME/.config/fish/config.fish" config_files="$HOME/.config/fish/config.fish"
;; ;;
zsh) zsh)
config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv" config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
;; ;;
bash) bash)
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-XP1DXs1Fcfog99rjMryki9mMqn1g1H4ykHx7WDsnrnw=", "x86_64-linux": "sha256-wENwhwRVfgoVyA9YNGcG+fAfu46JxK4xvNgiPbRt//s=",
"aarch64-darwin": "sha256-fupiqvXkW3Cl44K+n1cDz81vOboMXIHPHTey6TewX70=" "aarch64-darwin": "sha256-vm1DYl1erlbaqz5NHHlnZEMuFmidr/UkS84nIqLJ96Q="
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@types/bun": "1.3.5", "@types/bun": "1.3.4",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
+2 -1
View File
@@ -13,11 +13,12 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div> <div id="root" class="flex flex-col h-dvh"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.21", "version": "1.1.19",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -1,203 +1,267 @@
import { createMemo, createResource, Show } from "solid-js" import { createMemo, createResource, Show } from "solid-js"
import { Portal } from "solid-js/web" import { A, useNavigate, useParams } from "@solidjs/router"
import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
// import { useServer } from "@/context/server" import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() { export function SessionHeader() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const layout = useLayout() const layout = useLayout()
const params = useParams() const params = useParams()
const navigate = useNavigate()
const command = useCommand() const command = useCommand()
// const server = useServer() const server = useServer()
// const dialog = useDialog() const dialog = useDialog()
const sync = useSync() const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const parentSession = createMemo(() => {
const current = currentSession()
if (!current?.parentID) return undefined
return sync.data.session.find((s) => s.id === current.parentID)
})
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey())) const view = createMemo(() => layout.view(sessionKey()))
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) function navigateToProject(directory: string) {
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
// Only navigate if we're actually changing to a different session
if (session.id === params.id) return
navigate(`/${params.dir}/session/${session.id}`)
}
return ( return (
<> <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
<Show when={centerMount()}> <button
{(mount) => ( type="button"
<Portal mount={mount()}> class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
<button onClick={layout.mobileSidebar.toggle}
type="button" >
class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active" <Icon name="menu" size="small" />
onClick={() => command.trigger("file.open")} </button>
> <div class="px-4 flex items-center justify-between gap-4 w-full">
<Icon name="magnifying-glass" size="small" class="text-text-weak" /> <div class="flex items-center gap-3 min-w-0">
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span> <div class="flex items-center gap-2 min-w-0">
<Show when={hotkey()}> <div class="hidden xl:flex items-center gap-2">
{(keybind) => ( <Select
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak"> options={worktrees()}
{keybind()} current={sync.project?.worktree ?? projectDirectory()}
</span> label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)} )}
</Show> </Select>
</button> <div class="text-text-weaker">/</div>
</Portal>
)}
</Show>
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
{/* <div class="hidden md:flex items-center gap-1"> */}
{/* <Button */}
{/* size="small" */}
{/* variant="ghost" */}
{/* onClick={() => { */}
{/* dialog.show(() => <DialogSelectServer />) */}
{/* }} */}
{/* > */}
{/* <div */}
{/* classList={{ */}
{/* "size-1.5 rounded-full": true, */}
{/* "bg-icon-success-base": server.healthy() === true, */}
{/* "bg-icon-critical-base": server.healthy() === false, */}
{/* "bg-border-weak-base": server.healthy() === undefined, */}
{/* }} */}
{/* /> */}
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
{/* </Button> */}
{/* <SessionLspIndicator /> */}
{/* <SessionMcpIndicator /> */}
{/* </div> */}
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
onClick={() => view().terminal.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div> </div>
</Portal> <Show
)} when={parentSession()}
</Show> fallback={
</> <>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</>
}
>
<div class="flex items-center gap-2 min-w-0">
<Select
options={sessions()}
current={parentSession()}
placeholder="Back to parent session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={(session) => {
// Only navigate if selecting a different session than current parent
const currentParent = parentSession()
if (session && currentParent && session.id !== currentParent.id) {
navigateToSession(session)
}
}}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip value="Back to parent session">
<button
type="button"
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
onClick={() => navigateToSession(parentSession())}
>
<Icon name="arrow-left" size="small" class="text-icon-base" />
</button>
</Tooltip>
</div>
</div>
</Show>
</div>
<Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</div>
</header>
) )
} }
-115
View File
@@ -1,115 +0,0 @@
import { createEffect, createMemo, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const theme = useTheme()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const reserve = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
.__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
return (
<header class="h-10 shrink-0 bg-background-base flex items-center relative">
<div
classList={{
"flex items-center w-full min-w-0 pr-2": true,
"pl-2": !mac(),
}}
onMouseDown={drag}
>
<Show when={mac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
</Show>
<IconButton
icon="menu"
variant="ghost"
class="xl:hidden size-8 rounded-md"
onClick={layout.mobileSidebar.toggle}
/>
<TooltipKeybind
class="hidden xl:flex shrink-0"
placement="bottom"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
>
<IconButton
icon={layout.sidebar.opened() ? "layout-left" : "layout-right"}
variant="ghost"
class="size-8 rounded-md"
onClick={layout.sidebar.toggle}
/>
</TooltipKeybind>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="flex-1 h-full" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" />
<Show when={reserve()}>
<div class="w-[120px] h-full shrink-0" data-tauri-drag-region />
</Show>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto" />
</div>
</header>
)
}
+4 -17
View File
@@ -38,7 +38,6 @@ type State = {
config: Config config: Config
path: Path path: Path
session: Session[] session: Session[]
sessionTotal: number
session_status: { session_status: {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
@@ -99,7 +98,6 @@ function createGlobalSync() {
agent: [], agent: [],
command: [], command: [],
session: [], session: [],
sessionTotal: 0,
session_status: {}, session_status: {},
session_diff: {}, session_diff: {},
todo: {}, todo: {},
@@ -119,32 +117,21 @@ function createGlobalSync() {
async function loadSessions(directory: string) { async function loadSessions(directory: string) {
const [store, setStore] = child(directory) const [store, setStore] = child(directory)
const limit = store.limit globalSDK.client.session
.list({ directory })
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) => { .then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id) .filter((s) => !!s?.id)
.filter((s) => !s.time?.archived) .filter((s) => !s.time?.archived)
.slice() .slice()
.sort((a, b) => a.id.localeCompare(b.id)) .sort((a, b) => a.id.localeCompare(b.id))
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
if (sandboxWorkspace) {
setStore("session", reconcile(nonArchived, { key: "id" }))
return
}
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
// Include up to the limit, plus any updated in the last 4 hours // Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => { const sessions = nonArchived.filter((s, i) => {
if (i < limit) return true if (i < store.limit) return true
const updated = new Date(s.time?.updated ?? s.time?.created).getTime() const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo return updated > fourHoursAgo
}) })
// Store total session count (used for "load more" pagination)
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
}) })
.catch((err) => { .catch((err) => {
+1 -33
View File
@@ -47,34 +47,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSdk = useGlobalSDK() const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const server = useServer() const server = useServer()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const migrate = (value: unknown) => {
if (!isRecord(value)) return value
const sidebar = value.sidebar
if (!isRecord(sidebar)) return value
if (typeof sidebar.workspaces !== "boolean") return value
return {
...value,
sidebar: {
...sidebar,
workspaces: {},
workspacesDefault: sidebar.workspaces,
},
}
}
const target = Persist.global("layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ ...target, migrate }, Persist.global("layout", ["layout.v6"]),
createStore({ createStore({
sidebar: { sidebar: {
opened: false, opened: false,
width: 280, width: 280,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
}, },
terminal: { terminal: {
height: 280, height: 280,
@@ -326,16 +304,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) { resize(width: number) {
setStore("sidebar", "width", width) setStore("sidebar", "width", width)
}, },
workspaces(directory: string) {
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
},
toggleWorkspaces(directory: string) {
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
setStore("sidebar", "workspaces", directory, !current)
},
}, },
terminal: { terminal: {
height: createMemo(() => store.terminal.height), height: createMemo(() => store.terminal.height),
-3
View File
@@ -5,9 +5,6 @@ export type Platform = {
/** Platform discriminator */ /** Platform discriminator */
platform: "web" | "desktop" platform: "web" | "desktop"
/** Desktop OS (Tauri only) */
os?: "macos" | "windows" | "linux"
/** App version */ /** App version */
version?: string version?: string
+1 -1
View File
@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory) const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 400 const chunk = 200
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
-4
View File
@@ -5,7 +5,3 @@
cursor: default; cursor: default;
} }
} }
*[data-tauri-drag-region] {
app-region: drag;
}
File diff suppressed because it is too large Load Diff
+3 -16
View File
@@ -885,19 +885,6 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`) window.history.replaceState(null, "", `#${anchor(id)}`)
} }
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message) setActiveMessage(message)
@@ -909,7 +896,7 @@ export default function Page() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id)) const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior) if (el) el.scrollIntoView({ behavior, block: "start" })
}) })
updateHash(message.id) updateHash(message.id)
@@ -917,7 +904,7 @@ export default function Page() {
} }
const el = document.getElementById(anchor(message.id)) const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior) if (el) el.scrollIntoView({ behavior, block: "start" })
updateHash(message.id) updateHash(message.id)
} }
@@ -969,7 +956,7 @@ export default function Page() {
const hashTarget = document.getElementById(hash) const hashTarget = document.getElementById(hash)
if (hashTarget) { if (hashTarget) {
scrollToElement(hashTarget, "auto") hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
return return
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.21", "version": "1.1.19",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -1 +0,0 @@
../../../ui/src/assets/images/social-share-black.png
@@ -24,9 +24,6 @@ export function Footer() {
<div data-slot="cell"> <div data-slot="cell">
<a href="/docs">Docs</a> <a href="/docs">Docs</a>
</div> </div>
<div data-slot="cell">
<a href="/changelog">Changelog</a>
</div>
<div data-slot="cell"> <div data-slot="cell">
<a href="/discord">Discord</a> <a href="/discord">Discord</a>
</div> </div>
+134 -197
View File
@@ -14,7 +14,7 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 288px; height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0) 100%); background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
} }
[data-component="header"] { [data-component="header"] {
@@ -24,6 +24,9 @@
justify-content: center; justify-content: center;
padding-top: 40px; padding-top: 40px;
flex-shrink: 0; flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
} }
[data-component="content"] { [data-component="content"] {
@@ -55,20 +58,20 @@
margin: 0; margin: 0;
@media (min-width: 768px) { @media (min-width: 768px) {
font-size: 22px; font-size: 24px;
} }
} }
p { p {
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
font-size: 18px; font-size: 15px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 160%; line-height: 160%;
margin: 0; margin: 0;
@media (min-width: 768px) { @media (min-width: 768px) {
font-size: 22px; font-size: 18px;
} }
} }
} }
@@ -82,39 +85,24 @@
} }
svg { svg {
--hero-black-fill-from: hsl(0 0% 100%);
--hero-black-fill-to: hsl(0 0% 100% / 0%);
--hero-black-stroke-from: hsl(0 0% 100% / 60%);
--hero-black-stroke-to: hsl(0 0% 100% / 0%);
width: 100%; width: 100%;
max-width: 590px; max-width: 540px;
height: auto; height: auto;
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1)); filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1));
mask-image: linear-gradient(to bottom, black, transparent);
stroke-width: 1.5;
[data-slot="black-fill"] {
fill: url(#hero-black-fill-gradient);
}
[data-slot="black-stroke"] {
fill: url(#hero-black-stroke-gradient);
}
} }
} }
[data-slot="cta"] { [data-slot="cta"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 16px;
align-items: center; align-items: center;
text-align: center; text-align: center;
margin-top: -32px; margin-top: -40px;
width: 100%; width: 100%;
@media (min-width: 768px) { @media (min-width: 768px) {
margin-top: -16px; margin-top: -20px;
} }
[data-slot="heading"] { [data-slot="heading"] {
@@ -123,13 +111,12 @@
font-size: 18px; font-size: 18px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 160%; line-height: 160%; /* 28.8px */
span { span {
display: inline-block; display: inline-block;
} }
} }
[data-slot="subheading"] { [data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
font-size: 15px; font-size: 15px;
@@ -142,7 +129,6 @@
line-height: 160%; line-height: 160%;
} }
} }
[data-slot="button"] { [data-slot="button"] {
display: inline-flex; display: inline-flex;
height: 40px; height: 40px;
@@ -154,7 +140,7 @@
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
text-decoration: none; text-decoration: none;
color: #000; color: #000;
font-family: var(--font-mono); font-family: "JetBrains Mono Nerd Font";
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
@@ -168,16 +154,14 @@
transform: scale(0.98); transform: scale(0.98);
} }
} }
[data-slot="back-soon"] { [data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 160%; line-height: 160%; /* 20.8px */
} }
[data-slot="follow-us"] { [data-slot="follow-us"] {
display: inline-flex; display: inline-flex;
height: 40px; height: 40px;
@@ -188,7 +172,7 @@
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17); border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
font-family: var(--font-mono); font-family: "JetBrains Mono Nerd Font";
font-size: 14px; font-size: 14px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -201,98 +185,33 @@
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
width: 100%; width: 100%;
max-width: 680px; max-width: 540px;
padding: 0 20px; padding: 0 20px;
box-sizing: border-box;
@media (min-width: 768px) {
padding: 0;
}
} }
[data-slot="pricing-card"] { [data-slot="pricing-card"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; gap: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17); border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 5px; border-radius: 4px;
text-decoration: none; text-decoration: none;
background: #000; transition: border-color 0.15s ease;
background: transparent;
cursor: pointer;
text-align: left; text-align: left;
overflow: hidden;
width: 100%;
transition: border-color 200ms ease;
&:hover:not([data-selected="true"]) { &:hover {
border-color: rgba(255, 255, 255, 0.35); border-color: rgba(255, 255, 255, 0.35);
} }
[data-slot="card-trigger"] { [data-slot="icon"] {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 24px;
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: padding 200ms ease;
&:disabled {
cursor: default;
}
}
&[data-selected="true"] {
[data-slot="amount"] {
font-size: 22px;
}
[data-slot="terms"] {
animation: reveal 500ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
}
[data-slot="actions"] {
[data-slot="continue"] {
animation-delay: 200ms;
}
}
}
&[data-collapsed="true"] {
[data-slot="card-trigger"] {
padding: 20px 24px;
}
[data-slot="plan-header"] {
flex-direction: row;
}
[data-slot="amount"] {
font-size: 20px;
}
}
&[data-selected="false"][data-collapsed="false"] {
[data-slot="amount"] {
font-size: 22px;
}
[data-slot="period"],
[data-slot="multiplier"] {
font-size: 14px;
}
}
[data-slot="plan-header"] {
display: flex;
flex-direction: column;
width: 100%;
gap: 12px;
transition: gap 200ms ease;
}
[data-slot="plan-icon"] {
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
flex-shrink: 0;
} }
[data-slot="price"] { [data-slot="price"] {
@@ -300,31 +219,73 @@
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
line-height: 24px;
margin: 0;
} }
[data-slot="amount"] { [data-slot="amount"] {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500; font-weight: 500;
} }
[data-slot="content"] { [data-slot="period"] {
width: 100%;
}
[data-slot="period"],
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="billing"] {
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
font-size: 14px; font-size: 14px;
} }
[data-slot="multiplier"] { [data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
}
[data-slot="selected-plan"] {
display: flex;
flex-direction: column;
gap: 32px;
width: fit-content;
max-width: calc(100% - 40px);
margin: 0 auto;
}
[data-slot="selected-card"] {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
width: fit-content;
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before { &::before {
content: "·"; content: "·";
@@ -334,32 +295,27 @@
[data-slot="terms"] { [data-slot="terms"] {
list-style: none; list-style: none;
padding: 0 24px 24px 24px; padding: 0;
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
text-align: left; text-align: left;
width: 100%;
opacity: 0;
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
mask-repeat: no-repeat;
mask-size: 100% 200%;
mask-position: 0% 320%;
}
[data-slot="terms"] li { li {
color: rgba(255, 255, 255, 0.59); color: rgba(255, 255, 255, 0.59);
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.5;
padding-left: 16px; padding-left: 16px;
position: relative; position: relative;
white-space: nowrap;
&::before { &::before {
content: "▪"; content: "▪";
position: absolute; position: absolute;
left: 0; left: 0;
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
}
} }
} }
@@ -367,48 +323,41 @@
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-top: 8px; margin-top: 8px;
padding: 0 24px 24px 24px;
box-sizing: border-box;
width: 100%;
}
[data-slot="actions"] button, button,
[data-slot="actions"] a { a {
flex: 1; flex: 1;
display: inline-flex; display: inline-flex;
height: 48px; height: 48px;
padding: 0 16px; padding: 0 16px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: 4px; border-radius: 4px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition-property: background-color, border-color;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
}
[data-slot="cancel"] {
border: 1px solid var(--border-base, rgba(255, 255, 255, 0.17));
background: var(--surface-raised-base, rgba(255, 255, 255, 0.06));
background-clip: border-box;
color: rgba(255, 255, 255, 0.92);
&:hover {
background: var(--surface-raised-base, rgba(255, 255, 255, 0.08));
border-color: rgba(255, 255, 255, 0.25);
} }
}
[data-slot="continue"] { [data-slot="cancel"] {
background: rgb(255, 255, 255); background: transparent;
color: rgb(0, 0, 0); border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
&:hover { &:hover {
background: rgb(255, 255, 255, 0.9); border-color: rgba(255, 255, 255, 0.35);
}
}
[data-slot="continue"] {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
&:hover {
background: rgba(255, 255, 255, 0.25);
}
} }
} }
} }
@@ -419,7 +368,8 @@
font-size: 13px; font-size: 13px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 160%; line-height: 160%; /* 20.8px */
font-style: italic;
a { a {
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
@@ -491,7 +441,7 @@
[data-slot="multiplier"] { [data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
font-size: 13px; font-size: 14px;
&::before { &::before {
content: "·"; content: "·";
@@ -739,7 +689,7 @@
span, span,
a { a {
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
font-family: var(--font-mono); font-family: "JetBrains Mono Nerd Font";
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -749,7 +699,7 @@
[data-slot="github-stars"] { [data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25); color: rgba(255, 255, 255, 0.25);
font-family: var(--font-mono); font-family: "JetBrains Mono Nerd Font";
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -764,10 +714,9 @@
} }
} }
} }
[data-slot="anomaly-alt"] { [data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
font-family: var(--font-mono); font-family: "JetBrains Mono Nerd Font";
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -777,7 +726,7 @@
a { a {
color: rgba(255, 255, 255, 0.39); color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font", monospace; font-family: "JetBrains Mono Nerd Font";
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -791,15 +740,3 @@
} }
} }
} }
::view-transition-group(*) {
animation-duration: 200ms;
animation-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
}
@keyframes reveal {
100% {
mask-position: 0% 0%;
opacity: 1;
}
}
File diff suppressed because one or more lines are too long
@@ -2,8 +2,8 @@ import { Match, Switch } from "solid-js"
export const plans = [ export const plans = [
{ id: "20", multiplier: null }, { id: "20", multiplier: null },
{ id: "100", multiplier: "5x more usage than Black 20" }, { id: "100", multiplier: "6x more usage than Black 20" },
{ id: "200", multiplier: "20x more usage than Black 20" }, { id: "200", multiplier: "21x more usage than Black 20" },
] as const ] as const
export type PlanID = (typeof plans)[number]["id"] export type PlanID = (typeof plans)[number]["id"]
@@ -14,47 +14,28 @@ export function PlanIcon(props: { plan: string }) {
<Switch> <Switch>
<Match when={props.plan === "20"}> <Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 20 plan</title> <rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
</svg> </svg>
</Match> </Match>
<Match when={props.plan === "100"}> <Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 100 plan</title> <rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" /> <rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" /> <rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14.5" y="0.5" width="9" height="9" stroke="currentColor" /> <rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14.5" y="14.5" width="9" height="9" stroke="currentColor" />
</svg> </svg>
</Match> </Match>
<Match when={props.plan === "200"}> <Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 200 plan</title> <rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" /> <rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" /> <rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" /> <rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="15.5" width="3" height="3" stroke="currentColor" /> <rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="20.5" width="3" height="3" stroke="currentColor" /> <rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="5.5" y="0.5" width="3" height="3" stroke="currentColor" /> <rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="5.5" y="5.5" width="3" height="3" stroke="currentColor" /> <rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="5.5" y="10.5" width="3" height="3" stroke="currentColor" /> <rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="5.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="20.5" width="3" height="3" stroke="currentColor" />
</svg> </svg>
</Match> </Match>
</Switch> </Switch>
+62 -131
View File
@@ -1,148 +1,79 @@
import { A, useSearchParams } from "@solidjs/router" import { A, useSearchParams } from "@solidjs/router"
import { Title } from "@solidjs/meta" import { Title } from "@solidjs/meta"
import { createMemo, createSignal, For, onMount, Show } from "solid-js" import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { PlanIcon, plans } from "./common" import { PlanIcon, plans } from "./common"
export default function Black() { export default function Black() {
const [params] = useSearchParams() const [params] = useSearchParams()
const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null) const [selected, setSelected] = createSignal<string | null>((params.plan as string) || null)
const [mounted, setMounted] = createSignal(false) const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
onMount(() => {
requestAnimationFrame(() => setMounted(true))
})
const transition = (action: () => void) => {
if (mounted() && "startViewTransition" in document) {
;(document as any).startViewTransition(action)
return
}
action()
}
const select = (planId: string) => {
if (selected() === planId) {
return
}
transition(() => setSelected(planId))
}
const cancel = () => {
transition(() => setSelected(null))
}
return ( return (
<> <>
<Title>opencode</Title> <Title>opencode</Title>
<section data-slot="cta"> <section data-slot="cta">
<div data-slot="pricing"> <Switch>
<For each={plans}> <Match when={!selected()}>
{(plan) => { <div data-slot="pricing">
const isSelected = createMemo(() => selected() === plan.id) <For each={plans}>
const isCollapsed = createMemo(() => selected() !== null && selected() !== plan.id) {(plan) => (
<button type="button" onClick={() => setSelected(plan.id)} data-slot="pricing-card">
return ( <div data-slot="icon">
<article <PlanIcon plan={plan.id} />
data-slot="pricing-card"
data-plan-id={plan.id}
data-selected={isSelected() ? "true" : "false"}
data-collapsed={isCollapsed() ? "true" : "false"}
>
<button
type="button"
data-slot="card-trigger"
onClick={() => select(plan.id)}
disabled={isSelected()}
>
<div
data-slot="plan-header"
style={{
"view-transition-name": `plan-header-${plan.id}`,
}}
>
<div data-slot="plan-icon">
<PlanIcon plan={plan.id} />
</div>
<p
data-slot="price"
style={{
"view-transition-name": `price-${plan.id}`,
}}
>
<span
data-slot="amount"
style={{
"view-transition-name": `amount-${plan.id}`,
}}
>
${plan.id}
</span>
<Show when={!isSelected()}>
<span
data-slot="period"
style={{
"view-transition-name": `period-${plan.id}`,
}}
>
per month
</span>
</Show>
<Show when={isSelected()}>
<span
data-slot="billing"
style={{
"view-transition-name": `billing-${plan.id}`,
}}
>
per person billed monthly
</span>
</Show>
{plan.multiplier && (
<span
data-slot="multiplier"
style={{
"view-transition-name": `multiplier-${plan.id}`,
}}
>
{plan.multiplier}
</span>
)}
</p>
</div> </div>
<p data-slot="price">
<span data-slot="amount">${plan.id}</span> <span data-slot="period">per month</span>
<Show when={plan.multiplier}>
<span data-slot="multiplier">{plan.multiplier}</span>
</Show>
</p>
</button> </button>
)}
<Show when={isSelected()}> </For>
<div data-slot="content"> </div>
<ul data-slot="terms"> <p data-slot="fine-print">
<li>You will be added to the waitlist and activated in batches</li> Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
<li>Card won't be charged until subscription is active</li> </p>
<li>Not unlimited - limits apply and may be adjusted dynamically</li> </Match>
<li>Heavily automated usage will hit limits quickly</li> <Match when={selectedPlan()}>
<li>Plans may be discontinued</li> {(plan) => (
<li>Can cancel subscription at anytime</li> <div data-slot="selected-plan">
<li>Cannot issue refunds for consumed subscriptions</li> <div data-slot="selected-card">
</ul> <div data-slot="icon">
<div data-slot="actions"> <PlanIcon plan={plan().id} />
<button type="button" onClick={cancel} data-slot="cancel"> </div>
Cancel <p data-slot="price">
</button> <span data-slot="amount">${plan().id}</span>{" "}
<a href={`/black/subscribe/${plan.id}`} data-slot="continue"> <span data-slot="period">per person billed monthly</span>
Continue <Show when={plan().multiplier}>
</a> <span data-slot="multiplier">{plan().multiplier}</span>
</div> </Show>
</div> </p>
</Show> <ul data-slot="terms">
</article> <li>Your subscription will not start immediately</li>
) <li>You will be added to the waitlist and activated soon</li>
}} <li>Your card will be only charged when your subscription is activated</li>
</For> <li>Usage limits apply, heavily automated use may reach limits sooner</li>
</div> <li>Subscriptions for individuals, contact Enterprise for teams</li>
<p data-slot="fine-print"> <li>Limits may be adjusted and plans may be discontinued in the future</li>
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A> <li>Cancel your subscription at anytime</li>
</p> </ul>
<div data-slot="actions">
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
Cancel
</button>
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
Continue
</a>
</div>
</div>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</div>
)}
</Match>
</Switch>
</section> </section>
</> </>
) )
@@ -18,10 +18,10 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]> const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
const getWorkspaces = query(async (plan: string) => { const getWorkspaces = query(async () => {
"use server" "use server"
const actor = await getActor() const actor = await getActor()
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan) if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe")
return withActor(async () => { return withActor(async () => {
return Database.use((tx) => return Database.use((tx) =>
tx tx
@@ -258,16 +258,15 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data
} }
export default function BlackSubscribe() { export default function BlackSubscribe() {
const params = useParams() const workspaces = createAsync(() => getWorkspaces())
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
const workspaces = createAsync(() => getWorkspaces(plan))
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined) const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined) const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
const [failure, setFailure] = createSignal<string | undefined>(undefined) const [failure, setFailure] = createSignal<string | undefined>(undefined)
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined) const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined) const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
const params = useParams()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
// Resolve stripe promise once // Resolve stripe promise once
createEffect(() => { createEffect(() => {
@@ -1,477 +0,0 @@
::selection {
background: var(--color-background-interactive);
color: var(--color-text-strong);
@media (prefers-color-scheme: dark) {
background: var(--color-background-interactive);
color: var(--color-text-inverted);
}
}
[data-page="changelog"] {
--color-background: hsl(0, 20%, 99%);
--color-background-weak: hsl(0, 8%, 97%);
--color-background-weak-hover: hsl(0, 8%, 94%);
--color-background-strong: hsl(0, 5%, 12%);
--color-background-strong-hover: hsl(0, 5%, 18%);
--color-background-interactive: hsl(62, 84%, 88%);
--color-background-interactive-weaker: hsl(64, 74%, 95%);
--color-text: hsl(0, 1%, 39%);
--color-text-weak: hsl(0, 1%, 60%);
--color-text-weaker: hsl(30, 2%, 81%);
--color-text-strong: hsl(0, 5%, 12%);
--color-text-inverted: hsl(0, 20%, 99%);
--color-border: hsl(30, 2%, 81%);
--color-border-weak: hsl(0, 1%, 85%);
--color-icon: hsl(0, 1%, 55%);
background: var(--color-background);
font-family: var(--font-mono);
color: var(--color-text);
padding-bottom: 5rem;
@media (prefers-color-scheme: dark) {
--color-background: hsl(0, 9%, 7%);
--color-background-weak: hsl(0, 6%, 10%);
--color-background-weak-hover: hsl(0, 6%, 15%);
--color-background-strong: hsl(0, 15%, 94%);
--color-background-strong-hover: hsl(0, 15%, 97%);
--color-background-interactive: hsl(62, 100%, 90%);
--color-background-interactive-weaker: hsl(60, 20%, 8%);
--color-text: hsl(0, 4%, 71%);
--color-text-weak: hsl(0, 2%, 49%);
--color-text-weaker: hsl(0, 3%, 28%);
--color-text-strong: hsl(0, 15%, 94%);
--color-text-inverted: hsl(0, 9%, 7%);
--color-border: hsl(0, 3%, 28%);
--color-border-weak: hsl(0, 4%, 23%);
--color-icon: hsl(10, 3%, 43%);
}
/* Header styles - copied from download */
[data-component="top"] {
padding: 24px 5rem;
height: 80px;
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-background);
border-bottom: 1px solid var(--color-border-weak);
z-index: 10;
@media (max-width: 60rem) {
padding: 24px 1.5rem;
}
img {
height: 34px;
width: auto;
}
[data-component="nav-desktop"] {
ul {
display: flex;
justify-content: space-between;
align-items: center;
gap: 48px;
@media (max-width: 55rem) {
gap: 32px;
}
@media (max-width: 48rem) {
gap: 24px;
}
li {
display: inline-block;
a {
text-decoration: none;
span {
color: var(--color-text-weak);
}
}
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
[data-slot="cta-button"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
text-decoration: none;
@media (max-width: 55rem) {
display: none;
}
}
[data-slot="cta-button"]:hover {
background: var(--color-background-strong-hover);
text-decoration: none;
}
}
}
@media (max-width: 40rem) {
display: none;
}
}
[data-component="nav-mobile"] {
button > svg {
color: var(--color-icon);
}
}
[data-component="nav-mobile-toggle"] {
border: none;
background: none;
outline: none;
height: 40px;
width: 40px;
cursor: pointer;
margin-right: -8px;
}
[data-component="nav-mobile-toggle"]:hover {
background: var(--color-background-weak);
}
[data-component="nav-mobile"] {
display: none;
@media (max-width: 40rem) {
display: block;
[data-component="nav-mobile-icon"] {
cursor: pointer;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
[data-component="nav-mobile-menu-list"] {
position: fixed;
background: var(--color-background);
top: 80px;
left: 0;
right: 0;
height: 100vh;
ul {
list-style: none;
padding: 20px 0;
li {
a {
text-decoration: none;
padding: 20px;
display: block;
span {
color: var(--color-text-weak);
}
}
a:hover {
background: var(--color-background-weak);
}
}
}
}
}
}
[data-slot="logo dark"] {
display: none;
}
@media (prefers-color-scheme: dark) {
[data-slot="logo light"] {
display: none;
}
[data-slot="logo dark"] {
display: block;
}
}
}
[data-component="footer"] {
border-top: 1px solid var(--color-border-weak);
display: flex;
flex-direction: row;
margin-top: 4rem;
@media (max-width: 65rem) {
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="cell"] {
flex: 1;
text-align: center;
a {
text-decoration: none;
padding: 2rem 0;
width: 100%;
display: block;
span {
color: var(--color-text-weak);
@media (max-width: 40rem) {
display: none;
}
}
}
a:hover {
background: var(--color-background-weak);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
[data-slot="cell"] + [data-slot="cell"] {
border-left: 1px solid var(--color-border-weak);
@media (max-width: 40rem) {
border-left: none;
}
}
@media (max-width: 25rem) {
flex-wrap: wrap;
[data-slot="cell"] {
flex: 1 0 100%;
border-left: none;
border-top: 1px solid var(--color-border-weak);
}
[data-slot="cell"]:nth-child(1) {
border-top: none;
}
}
}
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
border: 1px solid var(--color-border-weak);
border-top: none;
@media (max-width: 65rem) {
border: none;
}
}
[data-component="content"] {
padding: 6rem 5rem;
@media (max-width: 60rem) {
padding: 4rem 1.5rem;
}
}
[data-component="legal"] {
color: var(--color-text-weak);
text-align: center;
padding: 2rem 5rem;
display: flex;
gap: 32px;
justify-content: center;
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
}
/* Changelog Hero */
[data-component="changelog-hero"] {
margin-bottom: 4rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--color-border-weak);
@media (max-width: 50rem) {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-strong);
margin-bottom: 8px;
}
p {
color: var(--color-text);
}
}
/* Releases */
[data-component="releases"] {
display: flex;
flex-direction: column;
gap: 0;
}
[data-component="release"] {
display: grid;
grid-template-columns: 180px 1fr;
gap: 3rem;
padding: 2rem 0;
border-bottom: 1px solid var(--color-border-weak);
@media (max-width: 50rem) {
grid-template-columns: 1fr;
gap: 1rem;
}
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
}
header {
display: flex;
flex-direction: column;
gap: 4px;
@media (max-width: 50rem) {
flex-direction: row;
align-items: center;
gap: 12px;
}
[data-slot="version"] {
a {
font-weight: 600;
color: var(--color-text-strong);
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
time {
color: var(--color-text-weak);
font-size: 14px;
}
}
[data-slot="content"] {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
[data-component="section"] {
h3 {
font-size: 14px;
font-weight: 600;
color: var(--color-text-strong);
margin-bottom: 8px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
li {
color: var(--color-text);
line-height: 1.5;
padding-left: 16px;
position: relative;
&::before {
content: "-";
position: absolute;
left: 0;
color: var(--color-text-weak);
}
[data-slot="author"] {
color: var(--color-text-weak);
font-size: 13px;
margin-left: 4px;
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
}
[data-component="contributors"] {
font-size: 13px;
color: var(--color-text-weak);
padding-top: 0.5rem;
span {
color: var(--color-text-weak);
}
a {
color: var(--color-text);
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
a {
color: var(--color-text-strong);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
text-decoration-thickness: 2px;
}
}
}
@@ -1,147 +0,0 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
import { createAsync, query } from "@solidjs/router"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { config } from "~/config"
import { For, Show } from "solid-js"
type Release = {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
const getReleases = query(async () => {
"use server"
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
cf: {
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
if (!response.ok) return []
return response.json() as Promise<Release[]>
}, "releases.get")
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
}
function parseMarkdown(body: string) {
const lines = body.split("\n")
const sections: { title: string; items: string[] }[] = []
let current: { title: string; items: string[] } | null = null
let skip = false
for (const line of lines) {
if (line.startsWith("## ")) {
if (current) sections.push(current)
const title = line.slice(3).trim()
current = { title, items: [] }
skip = false
} else if (line.startsWith("**Thank you")) {
skip = true
} else if (line.startsWith("- ") && !skip) {
current?.items.push(line.slice(2).trim())
}
}
if (current) sections.push(current)
return { sections }
}
function ReleaseItem(props: { item: string }) {
const parts = () => {
const match = props.item.match(/^(.+?)(\s*\(@([\w-]+)\))?$/)
if (match) {
return {
text: match[1],
username: match[3],
}
}
return { text: props.item, username: undefined }
}
return (
<li>
<span>{parts().text}</span>
<Show when={parts().username}>
<a data-slot="author" href={`https://github.com/${parts().username}`} target="_blank" rel="noopener noreferrer">
(@{parts().username})
</a>
</Show>
</li>
)
}
export default function Changelog() {
const releases = createAsync(() => getReleases())
return (
<main data-page="changelog">
<Title>OpenCode | Changelog</Title>
<Link rel="canonical" href={`${config.baseUrl}/changelog`} />
<Meta name="description" content="OpenCode release notes and changelog" />
<div data-component="container">
<Header hideGetStarted />
<div data-component="content">
<section data-component="changelog-hero">
<h1>Changelog</h1>
<p>New updates and improvements to OpenCode</p>
</section>
<section data-component="releases">
<For each={releases()}>
{(release) => {
const parsed = () => parseMarkdown(release.body || "")
return (
<article data-component="release">
<header>
<div data-slot="version">
<a href={release.html_url} target="_blank" rel="noopener noreferrer">
{release.tag_name}
</a>
</div>
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
</header>
<div data-slot="content">
<For each={parsed().sections}>
{(section) => (
<div data-component="section">
<h3>{section.title}</h3>
<ul>
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</div>
)}
</For>
</div>
</article>
)
}}
</For>
</section>
<Footer />
</div>
</div>
<Legal />
</main>
)
}
@@ -34,6 +34,7 @@
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--color-text); color: var(--color-text);
padding-bottom: 5rem; padding-bottom: 5rem;
overflow-x: hidden;
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
--color-background: hsl(0, 9%, 7%); --color-background: hsl(0, 9%, 7%);
@@ -441,8 +441,7 @@ export default function Download() {
</li> </li>
<li> <li>
<Faq question="Can I only use OpenCode in the terminal?"> <Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "} Not anymore! OpenCode is now available as an app for your desktop.
<a href="/docs/cli/#web">web</a>!
</Faq> </Faq>
</li> </li>
<li> <li>
+1 -2
View File
@@ -692,8 +692,7 @@ export default function Home() {
</li> </li>
<li> <li>
<Faq question="Can I only use OpenCode in the terminal?"> <Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "} Not anymore! OpenCode is now available as an app for your desktop.
<a href="/docs/web">web</a>!
</Faq> </Faq>
</li> </li>
<li> <li>
@@ -5,58 +5,4 @@
align-items: center; align-items: center;
gap: var(--space-4); gap: var(--space-4);
} }
[data-slot="usage"] {
display: flex;
gap: var(--space-6);
margin-top: var(--space-4);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-4);
}
}
[data-slot="usage-item"] {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-header"] {
display: flex;
justify-content: space-between;
align-items: baseline;
}
[data-slot="usage-label"] {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text);
}
[data-slot="usage-value"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="progress"] {
height: 8px;
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
[data-slot="progress-bar"] {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width 0.3s ease;
}
[data-slot="reset-time"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
} }
@@ -1,58 +1,10 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Show } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Black } from "@opencode-ai/console-core/black.js"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common" import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css" import styles from "./black-section.module.css"
const querySubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const row = await Database.use((tx) =>
tx
.select({
rollingUsage: SubscriptionTable.rollingUsage,
fixedUsage: SubscriptionTable.fixedUsage,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
})
.from(SubscriptionTable)
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
.then((r) => r[0]),
)
if (!row) return null
return {
rollingUsage: Black.analyzeRollingUsage({
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weeklyUsage: Black.analyzeWeeklyUsage({
usage: row.fixedUsage ?? 0,
timeUpdated: row.timeFixedUpdated ?? new Date(),
}),
}
}, workspaceID)
}, "subscription.get")
function formatResetTime(seconds: number) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
if (minutes === 0) return "a few seconds"
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
}
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server" "use server"
return json( return json(
@@ -74,7 +26,6 @@ export function BlackSection() {
const params = useParams() const params = useParams()
const sessionAction = useAction(createSessionUrl) const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl) const sessionSubmission = useSubmission(createSessionUrl)
const subscription = createAsync(() => querySubscription(params.id!))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
sessionRedirecting: false, sessionRedirecting: false,
}) })
@@ -102,32 +53,6 @@ export function BlackSection() {
</button> </button>
</div> </div>
</div> </div>
<Show when={subscription()}>
{(sub) => (
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">5-hour Usage</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">Weekly Usage</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
</div>
</div>
)}
</Show>
</section> </section>
) )
} }
@@ -3,6 +3,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { action, json, query } from "@solidjs/router" import { action, json, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
@@ -95,22 +96,11 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
return withActor(async () => { return withActor(async () => {
const billing = await Billing.get() const billing = await Billing.get()
return { return {
customerID: billing.customerID, ...billing,
paymentMethodID: billing.paymentMethodID,
paymentMethodType: billing.paymentMethodType,
paymentMethodLast4: billing.paymentMethodLast4,
balance: billing.balance,
reload: billing.reload,
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT, reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN, reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER, reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN, reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
monthlyLimit: billing.monthlyLimit,
monthlyUsage: billing.monthlyUsage,
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
reloadError: billing.reloadError,
timeReloadError: billing.timeReloadError,
subscriptionID: billing.subscriptionID,
} }
}, workspaceID) }, workspaceID)
}, "billing.get") }, "billing.get")
@@ -9,7 +9,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js" import { ZenData } from "@opencode-ai/console-core/model.js"
import { Black, BlackData } from "@opencode-ai/console-core/black.js" import { BlackData } from "@opencode-ai/console-core/black.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
@@ -495,28 +495,27 @@ export async function handler(
// Check weekly limit // Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) { if (sub.fixedUsage && sub.timeFixedUpdated) {
const result = Black.analyzeWeeklyUsage({ const week = getWeekBounds(now)
usage: sub.fixedUsage, if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
timeUpdated: sub.timeFixedUpdated, const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
})
if (result.status === "rate-limited")
throw new SubscriptionError( throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`, `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
result.resetInSec, retryAfter,
) )
}
} }
// Check rolling limit // Check rolling limit
if (sub.rollingUsage && sub.timeRollingUpdated) { if (sub.rollingUsage && sub.timeRollingUpdated) {
const result = Black.analyzeRollingUsage({ const rollingWindowMs = black.rollingWindow * 3600 * 1000
usage: sub.rollingUsage, const windowStart = new Date(now.getTime() - rollingWindowMs)
timeUpdated: sub.timeRollingUpdated, if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
}) const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
if (result.status === "rate-limited")
throw new SubscriptionError( throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`, `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
result.resetInSec, retryAfter,
) )
}
} }
return return
+1 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.21", "version": "1.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -32,7 +32,6 @@
"promote-models-to-dev": "script/promote-models.ts dev", "promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production", "promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev", "pull-models-from-dev": "script/pull-models.ts dev",
"pull-models-from-prod": "script/pull-models.ts production",
"update-black": "script/update-black.ts", "update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev", "promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production", "promote-black-to-prod": "script/promote-black.ts production",
@@ -1,41 +0,0 @@
import { subscribe } from "diagnostics_channel"
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun script/foo.ts <workspaceID>")
process.exit(1)
}
console.log(`Removing from Black waitlist`)
const billing = await Database.use((tx) =>
tx
.select({
subscriptionPlan: BillingTable.subscriptionPlan,
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!billing?.timeSubscriptionBooked) {
console.error(`Error: Workspace is not on the waitlist`)
process.exit(1)
}
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscriptionPlan: null,
timeSubscriptionBooked: null,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
console.log(`Done`)
@@ -1,6 +1,4 @@
import { Billing } from "../src/billing.js" import { Billing } from "../src/billing.js"
import { Database, eq } from "../src/drizzle/index.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
// get input from command line // get input from command line
const workspaceID = process.argv[2] const workspaceID = process.argv[2]
@@ -11,19 +9,6 @@ if (!workspaceID || !dollarAmount) {
process.exit(1) process.exit(1)
} }
// check workspace exists
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
if (!workspace) {
console.error("Error: Workspace not found")
process.exit(1)
}
const amountInDollars = parseFloat(dollarAmount) const amountInDollars = parseFloat(dollarAmount)
if (isNaN(amountInDollars) || amountInDollars <= 0) { if (isNaN(amountInDollars) || amountInDollars <= 0) {
console.error("Error: dollarAmount must be a positive number") console.error("Error: dollarAmount must be a positive number")
+2 -12
View File
@@ -113,13 +113,8 @@ async function printWorkspace(workspaceID: string) {
.select({ .select({
balance: BillingTable.balance, balance: BillingTable.balance,
customerID: BillingTable.customerID, customerID: BillingTable.customerID,
reload: BillingTable.reload, subscriptionID: BillingTable.subscriptionID,
subscription: { subscriptionCouponID: BillingTable.subscriptionCouponID,
id: BillingTable.subscriptionID,
couponID: BillingTable.subscriptionCouponID,
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
},
}) })
.from(BillingTable) .from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id)) .where(eq(BillingTable.workspaceID, workspace.id))
@@ -128,11 +123,6 @@ async function printWorkspace(workspaceID: string) {
rows.map((row) => ({ rows.map((row) => ({
...row, ...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`, balance: `$${(row.balance / 100000000).toFixed(2)}`,
subscription: row.subscription.id
? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}`
: row.subscription.booked
? `Waitlist ${row.subscription.plan} plan`
: undefined,
}))[0], }))[0],
), ),
) )
+16 -1
View File
@@ -25,7 +25,22 @@ export namespace Billing {
export const get = async () => { export const get = async () => {
return Database.use(async (tx) => return Database.use(async (tx) =>
tx tx
.select() .select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
balance: BillingTable.balance,
reload: BillingTable.reload,
reloadAmount: BillingTable.reloadAmount,
reloadTrigger: BillingTable.reloadTrigger,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadError: BillingTable.reloadError,
timeReloadError: BillingTable.timeReloadError,
})
.from(BillingTable) .from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace())) .where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((r) => r[0]), .then((r) => r[0]),
-72
View File
@@ -1,8 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { fn } from "./util/fn" import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { centsToMicroCents } from "./util/price"
import { getWeekBounds } from "./util/date"
export namespace BlackData { export namespace BlackData {
const Schema = z.object({ const Schema = z.object({
@@ -20,73 +18,3 @@ export namespace BlackData {
return Schema.parse(json) return Schema.parse(json)
}) })
} }
export namespace Black {
export const analyzeRollingUsage = fn(
z.object({
usage: z.number().int(),
timeUpdated: z.date(),
}),
({ usage, timeUpdated }) => {
const now = new Date()
const black = BlackData.get()
const rollingWindowMs = black.rollingWindow * 3600 * 1000
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
const windowStart = new Date(now.getTime() - rollingWindowMs)
if (timeUpdated < windowStart) {
return {
status: "ok" as const,
resetInSec: black.rollingWindow * 3600,
usagePercent: 0,
}
}
const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs)
if (usage < rollingLimitInMicroCents) {
return {
status: "ok" as const,
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
}
}
return {
status: "rate-limited" as const,
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
usagePercent: 100,
}
},
)
export const analyzeWeeklyUsage = fn(
z.object({
usage: z.number().int(),
timeUpdated: z.date(),
}),
({ usage, timeUpdated }) => {
const black = BlackData.get()
const now = new Date()
const week = getWeekBounds(now)
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
if (timeUpdated < week.start) {
return {
status: "ok" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: 0,
}
}
if (usage < fixedLimitInMicroCents) {
return {
status: "ok" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
}
}
return {
status: "rate-limited" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: 100,
}
},
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.21", "version": "1.1.19",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.21", "version": "1.1.19",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -17,7 +17,7 @@
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh"></div> <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/index.tsx" type="module"></script> <script src="/src/index.tsx" type="module"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.21", "version": "1.1.19",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
-1
View File
@@ -41,7 +41,6 @@ semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
uuid = { version = "1.19.0", features = ["v4"] } uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2" gtk = "0.18.2"
webkit2gtk = "=2.0.1" webkit2gtk = "=2.0.1"
@@ -7,7 +7,6 @@
"core:default", "core:default",
"opener:default", "opener:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:webview:allow-set-webview-zoom", "core:webview:allow-set-webview-zoom",
"core:window:allow-is-focused", "core:window:allow-is-focused",
"core:window:allow-show", "core:window:allow-show",
+19 -27
View File
@@ -14,7 +14,7 @@ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
@@ -237,14 +237,7 @@ pub fn run() {
} }
})) }))
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin( .plugin(tauri_plugin_window_state::Builder::new().build())
tauri_plugin_window_state::Builder::new()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
- tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
@@ -275,30 +268,29 @@ pub fn run() {
.map(|m| m.size().to_logical(m.scale_factor())) .map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080)); .unwrap_or(LogicalSize::new(1920, 1080));
let config = app #[allow(unused_mut)]
.config() let mut window_builder =
.app WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.windows .title("OpenCode")
.iter() .inner_size(size.width as f64, size.height as f64)
.find(|w| w.label == "main") .decorations(true)
.expect("main window config missing"); .zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
let window_builder = WebviewWindowBuilder::from_config(&app, config) .initialization_script(format!(
.expect("Failed to create window builder from config") r#"
.inner_size(size.width as f64, size.height as f64)
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}}; window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled}; window.__OPENCODE__.updaterEnabled = {updater_enabled};
"# "#
)); ));
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let window_builder = window_builder {
.title_bar_style(tauri::TitleBarStyle::Overlay) window_builder = window_builder
.hidden_title(true); .title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
let _window = window_builder.build().expect("Failed to create window"); window_builder.build().expect("Failed to create window");
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
app.manage(ServerState::new(None, rx)); app.manage(ServerState::new(None, rx));
@@ -11,20 +11,6 @@
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenCode",
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
}
],
"withGlobalTauri": true, "withGlobalTauri": true,
"security": { "security": {
"csp": null "csp": null
@@ -2,27 +2,6 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCode", "productName": "OpenCode",
"identifier": "ai.opencode.desktop", "identifier": "ai.opencode.desktop",
"app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenCode",
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
}
],
"withGlobalTauri": true,
"security": {
"csp": null
},
"macOSPrivateApi": true
},
"bundle": { "bundle": {
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"icon": [ "icon": [
+4 -21
View File
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store" import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
import { UPDATER_ENABLED } from "./updater" import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu" import { createMenu } from "./menu"
@@ -30,11 +30,6 @@ let update: Update | null = null
const createPlatform = (password: Accessor<string | null>): Platform => ({ const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop", platform: "desktop",
os: (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
return undefined
})(),
version: pkg.version, version: pkg.version,
async openDirectoryPickerDialog(opts) { async openDirectoryPickerDialog(opts) {
@@ -301,24 +296,12 @@ render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null) const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword()) const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
}
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
document.removeEventListener("click", handleClick)
})
})
return ( return (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<ServerGate> <ServerGate>
{(data) => { {(data) => {
setServerPassword(data().password) setServerPassword(data().password)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.21", "version": "1.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.21" version = "1.1.19"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.19/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.21", "version": "1.1.19",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.21", "version": "1.1.19",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -70,7 +70,7 @@
"@ai-sdk/vercel": "1.0.31", "@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1", "@gitlab/gitlab-ai-provider": "3.1.0",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
@@ -82,8 +82,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.73", "@opentui/core": "0.1.72",
"@opentui/solid": "0.1.73", "@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
+1 -7
View File
@@ -13,8 +13,6 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda" import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@@ -90,13 +88,9 @@ export namespace Agent {
PermissionNext.fromConfig({ PermissionNext.fromConfig({
question: "allow", question: "allow",
plan_exit: "allow", plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: { edit: {
"*": "deny", "*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow", ".opencode/plans/*.md": "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
}, },
}), }),
user, user,
+2 -2
View File
@@ -338,9 +338,9 @@ export const AuthLoginCommand = cmd({
prompts.log.info( prompts.log.info(
"Amazon Bedrock authentication priority:\n" + "Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + " 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" + "Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
) )
} }
@@ -5,7 +5,7 @@ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind" import { Keybind } from "@/util/keybind"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
export function useConnected() { export function useConnected() {
@@ -19,7 +19,6 @@ export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const keybind = useKeybind()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>() const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
@@ -208,14 +207,14 @@ export function DialogModel(props: { providerID?: string }) {
<DialogSelect <DialogSelect
keybind={[ keybind={[
{ {
keybind: keybind.all.model_provider_list?.[0], keybind: Keybind.parse("ctrl+a")[0],
title: connected() ? "Connect provider" : "View all providers", title: connected() ? "Connect provider" : "View all providers",
onTrigger() { onTrigger() {
dialog.replace(() => <DialogProvider />) dialog.replace(() => <DialogProvider />)
}, },
}, },
{ {
keybind: keybind.all.model_favorite_toggle?.[0], keybind: Keybind.parse("ctrl+f")[0],
title: "Favorite", title: "Favorite",
disabled: !connected(), disabled: !connected(),
onTrigger: (option) => { onTrigger: (option) => {
@@ -26,82 +26,67 @@ export function createDialogProviderOptions() {
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
const connected = createMemo(() => new Set(sync.data.provider_next.connected))
const options = createMemo(() => { const options = createMemo(() => {
return pipe( return pipe(
sync.data.provider_next.all, sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => { map((provider) => ({
const isConnected = connected().has(provider.id) title: provider.name,
return { value: provider.id,
title: provider.name, description: {
value: provider.id, opencode: "(Recommended)",
description: { anthropic: "(Claude Max or API key)",
opencode: "(Recommended)", openai: "(ChatGPT Plus/Pro or API key)",
anthropic: "(Claude Max or API key)", }[provider.id],
openai: "(ChatGPT Plus/Pro or API key)", category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
}[provider.id], async onSelect() {
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", const methods = sync.data.provider_auth[provider.id] ?? [
footer: isConnected ? "Connected" : undefined, {
async onSelect() { type: "api",
const methods = sync.data.provider_auth[provider.id] ?? [ label: "API key",
{ },
type: "api", ]
label: "API key", let index: number | null = 0
}, if (methods.length > 1) {
] index = await new Promise<number | null>((resolve) => {
let index: number | null = 0 dialog.replace(
if (methods.length > 1) { () => (
index = await new Promise<number | null>((resolve) => { <DialogSelect
dialog.replace( title="Select auth method"
() => ( options={methods.map((x, index) => ({
<DialogSelect title: x.label,
title="Select auth method" value: index,
options={methods.map((x, index) => ({ }))}
title: x.label, onSelect={(option) => resolve(option.value)}
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/> />
)) ),
} () => resolve(null),
if (result.data?.method === "auto") { )
dialog.replace(() => ( })
<AutoMethod }
providerID={provider.id} if (index == null) return
title={method.label} const method = methods[index]
index={index} if (method.type === "oauth") {
authorization={result.data!} const result = await sdk.client.provider.oauth.authorize({
/> providerID: provider.id,
)) method: index,
} })
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
} }
if (method.type === "api") { if (result.data?.method === "auto") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />) dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
} }
}, }
} if (method.type === "api") {
}), return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
) )
}) })
return options return options
@@ -4,7 +4,7 @@ import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useKeybind } from "../context/keybind" import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
@@ -14,10 +14,9 @@ import "opentui-spinner/solid"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute()
const sync = useSync() const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme() const { theme } = useTheme()
const route = useRoute()
const sdk = useSDK() const sdk = useSDK()
const kv = useKV() const kv = useKV()
@@ -30,6 +29,8 @@ export function DialogSessionList() {
return result.data ?? [] return result.data ?? []
}) })
const deleteKeybind = "ctrl+d"
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
@@ -51,7 +52,7 @@ export function DialogSessionList() {
const status = sync.data.session_status?.[x.id] const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" const isWorking = status?.type === "busy"
return { return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
@@ -88,7 +89,7 @@ export function DialogSessionList() {
}} }}
keybind={[ keybind={[
{ {
keybind: keybind.all.session_delete?.[0], keybind: Keybind.parse(deleteKeybind)[0],
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
@@ -102,7 +103,7 @@ export function DialogSessionList() {
}, },
}, },
{ {
keybind: keybind.all.session_rename?.[0], keybind: Keybind.parse("ctrl+r")[0],
title: "rename", title: "rename",
onTrigger: async (option) => { onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)
@@ -2,8 +2,8 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash" import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string { function getRelativeTime(timestamp: number): string {
@@ -30,7 +30,6 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog() const dialog = useDialog()
const stash = usePromptStash() const stash = usePromptStash()
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<number>() const [toDelete, setToDelete] = createSignal<number>()
@@ -42,7 +41,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return { return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: index, value: index,
description: getRelativeTime(entry.timestamp), description: getRelativeTime(entry.timestamp),
@@ -70,7 +69,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
}} }}
keybind={[ keybind={[
{ {
keybind: keybind.all.stash_delete?.[0], keybind: Keybind.parse("ctrl+d")[0],
title: "delete", title: "delete",
onTrigger: (option) => { onTrigger: (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
@@ -1,85 +1,24 @@
import { TextAttributes, RGBA } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js" import { For } from "solid-js"
import { useTheme, tint } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
// Shadow markers (rendered chars in parens): const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
// _ = full shadow cell (space with bg=shadow)
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
// ~ = shadow top only (▀ with fg=shadow)
const SHADOW_MARKER = /[_^~]/
const LOGO_LEFT = [` `, `█▀▀ █▀▀█ █▀▀█ █▀▀`, `__█ █__█ █^^^ █__█`, `▀▀▀▀ ▀▀▀ ▀▀▀▀ ▀~~`] const LOGO_RIGHT = [` `, `█▀▀ █▀▀█ █▀▀█ █▀▀`, `░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀ ▀▀▀▀ ▀▀▀`]
const LOGO_RIGHT = [``, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
export function Logo() { export function Logo() {
const { theme } = useTheme() const { theme } = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
const elements: JSX.Element[] = []
let i = 0
while (i < line.length) {
const rest = line.slice(i)
const markerIndex = rest.search(SHADOW_MARKER)
if (markerIndex === -1) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest}
</text>,
)
break
}
if (markerIndex > 0) {
elements.push(
<text fg={fg} attributes={attrs} selectable={false}>
{rest.slice(0, markerIndex)}
</text>,
)
}
const marker = rest[markerIndex]
switch (marker) {
case "_":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "}
</text>,
)
break
case "^":
elements.push(
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
case "~":
elements.push(
<text fg={shadow} attributes={attrs} selectable={false}>
</text>,
)
break
}
i += markerIndex + 1
}
return elements
}
return ( return (
<box> <box>
<For each={LOGO_LEFT}> <For each={LOGO_LEFT}>
{(line, index) => ( {(line, index) => (
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box> <text fg={theme.textMuted} selectable={false}>
<box flexDirection="row">{renderLine(LOGO_RIGHT[index()], theme.text, true)}</box> {line}
</text>
<text fg={theme.text} attributes={TextAttributes.BOLD} selectable={false}>
{LOGO_RIGHT[index()]}
</text>
</box> </box>
)} )}
</For> </For>
@@ -23,7 +23,6 @@ import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event" import { TuiEvent } from "../../event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { formatDuration } from "@/util/format"
import { createColors, createFrames } from "../../ui/spinner.ts" import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
@@ -1038,8 +1037,7 @@ export function Prompt(props: PromptProps) {
if (!r) return "" if (!r) return ""
const baseMessage = message() const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : "" const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds()) const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo return baseMessage + truncatedHint + retryInfo
} }
@@ -139,7 +139,7 @@ const TIPS = [
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info", "Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling", "Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})", "Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use", "Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
@@ -417,13 +417,6 @@ async function getCustomThemes() {
return result return result
} }
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
@@ -435,6 +428,13 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
return ansiToRgba(i) return ansiToRgba(i)
} }
const tint = (base: RGBA, overlay: RGBA, alpha: number) => {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
// Generate gray scale based on terminal background // Generate gray scale based on terminal background
const grays = generateGrayScale(bg, isDark) const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark) const textMuted = generateMutedTextColor(bg, isDark)
@@ -1894,10 +1894,10 @@ function Question(props: ToolProps<typeof QuestionTool>) {
<Switch> <Switch>
<Match when={props.metadata.answers}> <Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}> <BlockTool title="# Questions" part={props.part}>
<box gap={1}> <box>
<For each={props.input.questions ?? []}> <For each={props.input.questions ?? []}>
{(q, i) => ( {(q, i) => (
<box flexDirection="column"> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{q.question}</text> <text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text> <text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box> </box>
@@ -132,16 +132,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("editing", false) setStore("editing", false)
return return
} }
if (keybind.match("input_clear", evt)) {
evt.preventDefault()
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
return
}
if (evt.name === "return") { if (evt.name === "return") {
evt.preventDefault() evt.preventDefault()
const text = textarea?.plainText?.trim() ?? "" const text = textarea?.plainText?.trim() ?? ""
@@ -152,11 +142,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const inputs = [...store.custom] const inputs = [...store.custom]
inputs[store.tab] = "" inputs[store.tab] = ""
setStore("custom", inputs) setStore("custom", inputs)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
} }
const answers = [...store.answers]
if (prev) {
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
}
if (!prev) {
answers[store.tab] = []
}
setStore("answers", answers)
setStore("editing", false) setStore("editing", false)
return return
} }
@@ -210,16 +205,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
} else { } else {
const opts = options() const opts = options()
const total = opts.length + (custom() ? 1 : 0) const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
moveTo(index)
selectOption()
return
}
if (evt.name === "up" || evt.name === "k") { if (evt.name === "up" || evt.name === "k") {
evt.preventDefault() evt.preventDefault()
@@ -302,16 +287,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}> <box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}> <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{multi() {i() + 1}. {opt.label}
? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}`
: `${i() + 1}. ${opt.label}`}
</text> </text>
</box> </box>
<Show when={!multi()}> <text fg={theme.success}>{picked() ? "✓" : ""}</text>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
</Show>
</box> </box>
<box paddingLeft={3}> <box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text> <text fg={theme.textMuted}>{opt.description}</text>
</box> </box>
@@ -324,25 +304,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}> <box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}> <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{multi() {options().length + 1}. Type your own answer
? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer`
: `${options().length + 1}. Type your own answer`}
</text> </text>
</box> </box>
<Show when={!multi()}> <text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</Show>
</box> </box>
<Show when={store.editing}> <Show when={store.editing}>
<box paddingLeft={3}> <box paddingLeft={3}>
<textarea <textarea
ref={(val: TextareaRenderable) => { ref={(val: TextareaRenderable) => (textarea = val)}
textarea = val focused
queueMicrotask(() => {
val.focus()
val.gotoLineEnd()
})
}}
initialValue={input()} initialValue={input()}
placeholder="Type your own answer" placeholder="Type your own answer"
textColor={theme.text} textColor={theme.text}
@@ -372,13 +343,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const value = () => store.answers[index()]?.join(", ") ?? "" const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value()) const answered = () => Boolean(value())
return ( return (
<box paddingLeft={1}> <box flexDirection="row" gap={1} paddingLeft={1}>
<text> <text fg={theme.textMuted}>{q.header}:</text>
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "} <text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
<span style={{ fg: answered() ? theme.text : theme.error }}>
{answered() ? value() : "(not answered)"}
</span>
</text>
</box> </box>
) )
}} }}
@@ -237,10 +237,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={diff().length <= 2 || expanded.diff}> <Show when={diff().length <= 2 || expanded.diff}>
<For each={diff() || []}> <For each={diff() || []}>
{(item) => { {(item) => {
const file = createMemo(() => {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
if (!rest) return last
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return ( return (
<box flexDirection="row" gap={1} justifyContent="space-between"> <box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme.textMuted} truncate={true} wrapMode="none"> <text fg={theme.textMuted} wrapMode="char">
{item.file} {file()}
</text> </text>
<box flexDirection="row" gap={1} flexShrink={0}> <box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}> <Show when={item.additions}>
@@ -21,7 +21,7 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean skipFilter?: boolean
keybind?: { keybind?: {
keybind?: Keybind.Info keybind: Keybind.Info
title: string title: string
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
@@ -109,16 +109,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
createEffect( createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => { on([() => store.filter, () => props.current], ([filter, current]) => {
setTimeout(() => { if (filter.length > 0) {
if (filter.length > 0) { setStore("selected", 0)
moveTo(0, true) } else if (current) {
} else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) {
if (currentIndex >= 0) { setStore("selected", currentIndex)
moveTo(currentIndex, true)
}
} }
}, 0) }
scroll?.scrollTo(0)
}), }),
) )
@@ -130,7 +129,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(next) moveTo(next)
} }
function moveTo(next: number, center = false) { function moveTo(next: number) {
setStore("selected", next) setStore("selected", next)
props.onMove?.(selected()!) props.onMove?.(selected()!)
if (!scroll) return if (!scroll) return
@@ -139,18 +138,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}) })
if (!target) return if (!target) return
const y = target.y - scroll.y const y = target.y - scroll.y
if (center) { if (y >= scroll.height) {
const centerOffset = Math.floor(scroll.height / 2) scroll.scrollBy(y - scroll.height + 1)
scroll.scrollBy(y - centerOffset) }
} else { if (y < 0) {
if (y >= scroll.height) { scroll.scrollBy(y)
scroll.scrollBy(y - scroll.height + 1) if (isDeepEqual(flat()[0].value, selected()?.value)) {
} scroll.scrollTo(0)
if (y < 0) {
scroll.scrollBy(y)
if (isDeepEqual(flat()[0].value, selected()?.value)) {
scroll.scrollTo(0)
}
} }
} }
} }
@@ -172,7 +166,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
} }
for (const item of props.keybind ?? []) { for (const item of props.keybind ?? []) {
if (item.disabled || !item.keybind) continue if (item.disabled) continue
if (Keybind.match(item.keybind, keybind.parse(evt))) { if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected() const s = selected()
if (s) { if (s) {
@@ -194,7 +188,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
} }
props.ref?.(ref) props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled) ?? [])
return ( return (
<box gap={1} paddingBottom={1}> <box gap={1} paddingBottom={1}>
+3 -9
View File
@@ -16,7 +16,7 @@ export const UpgradeCommand = {
alias: "m", alias: "m",
describe: "installation method to use", describe: "installation method to use",
type: "string", type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"], choices: ["curl", "npm", "pnpm", "bun", "brew"],
}) })
}, },
handler: async (args: { target?: string; method?: string }) => { handler: async (args: { target?: string; method?: string }) => {
@@ -56,14 +56,8 @@ export const UpgradeCommand = {
const err = await Installation.upgrade(method, target).catch((err) => err) const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) { if (err) {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
// necessary because choco only allows install/upgrade in elevated terminals else if (err instanceof Error) prompts.log.error(err.message)
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again")
} else {
prompts.log.error(err.data.stderr)
}
} else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done") prompts.outro("Done")
return return
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export function FormatError(input: unknown) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.` return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
} }
if (ConfigMarkdown.FrontmatterError.isInstance(input)) { if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return input.data.message return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
} }
if (Config.InvalidError.isInstance(input)) if (Config.InvalidError.isInstance(input))
return [ return [
+13 -36
View File
@@ -19,8 +19,6 @@ import { BunProc } from "@/bun"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs" import { existsSync } from "fs"
import { Bus } from "@/bus"
import { Session } from "@/session"
export namespace Config { export namespace Config {
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
@@ -233,15 +231,8 @@ export namespace Config {
dot: true, dot: true,
cwd: dir, cwd: dir,
})) { })) {
const md = await ConfigMarkdown.parse(item).catch((err) => { const md = await ConfigMarkdown.parse(item)
const message = ConfigMarkdown.FrontmatterError.isInstance(err) if (!md.data) continue
? err.data.message
: `Failed to parse command ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const file = rel(item, patterns) ?? path.basename(item) const file = rel(item, patterns) ?? path.basename(item)
@@ -272,15 +263,8 @@ export namespace Config {
dot: true, dot: true,
cwd: dir, cwd: dir,
})) { })) {
const md = await ConfigMarkdown.parse(item).catch((err) => { const md = await ConfigMarkdown.parse(item)
const message = ConfigMarkdown.FrontmatterError.isInstance(err) if (!md.data) continue
? err.data.message
: `Failed to parse agent ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
const file = rel(item, patterns) ?? path.basename(item) const file = rel(item, patterns) ?? path.basename(item)
@@ -310,15 +294,8 @@ export namespace Config {
dot: true, dot: true,
cwd: dir, cwd: dir,
})) { })) {
const md = await ConfigMarkdown.parse(item).catch((err) => { const md = await ConfigMarkdown.parse(item)
const message = ConfigMarkdown.FrontmatterError.isInstance(err) if (!md.data) continue
? err.data.message
: `Failed to parse mode ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue
const config = { const config = {
name: path.basename(item, ".md"), name: path.basename(item, ".md"),
@@ -418,7 +395,9 @@ export namespace Config {
.int() .int()
.positive() .positive()
.optional() .optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), .describe(
"Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
),
}) })
.strict() .strict()
.meta({ .meta({
@@ -457,7 +436,9 @@ export namespace Config {
.int() .int()
.positive() .positive()
.optional() .optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), .describe(
"Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.",
),
}) })
.strict() .strict()
.meta({ .meta({
@@ -640,11 +621,7 @@ export namespace Config {
session_list: z.string().optional().default("<leader>l").describe("List all sessions"), session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"), session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"), session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), session_rename: z.string().optional().default("none").describe("Rename session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
session_share: z.string().optional().default("none").describe("Share current session"), session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
+2 -54
View File
@@ -14,60 +14,8 @@ export namespace ConfigMarkdown {
return Array.from(template.matchAll(SHELL_REGEX)) return Array.from(template.matchAll(SHELL_REGEX))
} }
export function preprocessFrontmatter(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split("\n")
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) { export async function parse(filePath: string) {
const raw = await Bun.file(filePath).text() const template = await Bun.file(filePath).text()
const template = preprocessFrontmatter(raw)
try { try {
const md = matter(template) const md = matter(template)
@@ -76,7 +24,7 @@ export namespace ConfigMarkdown {
throw new FrontmatterError( throw new FrontmatterError(
{ {
path: filePath, path: filePath,
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
}, },
{ cause: err }, { cause: err },
) )
+1 -1
View File
@@ -33,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }), fs.mkdir(Global.Path.bin, { recursive: true }),
]) ])
const CACHE_VERSION = "18" const CACHE_VERSION = "17"
const version = await Bun.file(path.join(Global.Path.cache, "version")) const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text() .text()
+5 -46
View File
@@ -83,14 +83,6 @@ export namespace Installation {
name: "brew" as const, name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(), command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
}, },
{
name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(),
},
{
name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
},
] ]
checks.sort((a, b) => { checks.sort((a, b) => {
@@ -103,9 +95,7 @@ export namespace Installation {
for (const check of checks) { for (const check of checks) {
const output = await check.command() const output = await check.command()
const installedName = if (output.includes(check.name === "brew" ? "opencode" : "opencode-ai")) {
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name return check.name
} }
} }
@@ -154,28 +144,20 @@ export namespace Installation {
}) })
break break
} }
case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}`
break
case "scoop":
cmd = $`scoop install extras/opencode@${target}`
break
default: default:
throw new Error(`Unknown method: ${method}`) throw new Error(`Unknown method: ${method}`)
} }
const result = await cmd.quiet().throws(false) const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
throw new UpgradeFailedError({
stderr: stderr,
})
}
log.info("upgraded", { log.info("upgraded", {
method, method,
target, target,
stdout: result.stdout.toString(), stdout: result.stdout.toString(),
stderr: result.stderr.toString(), stderr: result.stderr.toString(),
}) })
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
await $`${process.execPath} --version`.nothrow().quiet().text() await $`${process.execPath} --version`.nothrow().quiet().text()
} }
@@ -213,29 +195,6 @@ export namespace Installation {
.then((data: any) => data.version) .then((data: any) => data.version)
} }
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(res.statusText) if (!res.ok) throw new Error(res.statusText)
+4 -9
View File
@@ -109,7 +109,7 @@ export namespace MCP {
} }
// Convert MCP tool definition to AI SDK Tool type // Convert MCP tool definition to AI SDK Tool type
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise<Tool> { async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
const inputSchema = mcpTool.inputSchema const inputSchema = mcpTool.inputSchema
// Spread first, then override type to ensure it's always "object" // Spread first, then override type to ensure it's always "object"
@@ -119,6 +119,7 @@ export namespace MCP {
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"], properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false, additionalProperties: false,
} }
const config = await Config.get()
return dynamicTool({ return dynamicTool({
description: mcpTool.description ?? "", description: mcpTool.description ?? "",
@@ -132,7 +133,7 @@ export namespace MCP {
CallToolResultSchema, CallToolResultSchema,
{ {
resetTimeoutOnProgress: true, resetTimeoutOnProgress: true,
timeout, timeout: config.experimental?.mcp_timeout,
}, },
) )
}, },
@@ -555,10 +556,7 @@ export namespace MCP {
export async function tools() { export async function tools() {
const result: Record<string, Tool> = {} const result: Record<string, Tool> = {}
const s = await state() const s = await state()
const cfg = await Config.get()
const config = cfg.mcp ?? {}
const clientsSnapshot = await clients() const clientsSnapshot = await clients()
const defaultTimeout = cfg.experimental?.mcp_timeout
for (const [clientName, client] of Object.entries(clientsSnapshot)) { for (const [clientName, client] of Object.entries(clientsSnapshot)) {
// Only include tools from connected MCPs (skip disabled ones) // Only include tools from connected MCPs (skip disabled ones)
@@ -579,13 +577,10 @@ export namespace MCP {
if (!toolsResult) { if (!toolsResult) {
continue continue
} }
const mcpConfig = config[clientName]
const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
const timeout = entry?.timeout ?? defaultTimeout
for (const mcpTool of toolsResult.tools) { for (const mcpTool of toolsResult.tools) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout) result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
} }
} }
return result return result
+32
View File
@@ -361,6 +361,38 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
} }
} }
if (!provider.models["gpt-5.2-codex"]) {
const model = {
id: "gpt-5.2-codex",
providerID: "openai",
api: {
id: "gpt-5.2-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.2 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400000, output: 128000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2025-12-18",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.2-codex"] = model
}
// Zero out costs for Codex (included with ChatGPT subscription) // Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
model.cost = { model.cost = {
+2 -2
View File
@@ -15,7 +15,7 @@ import { CopilotAuthPlugin } from "./copilot"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.0"] const BUILTIN = ["opencode-anthropic-auth@0.0.8", "@gitlab/opencode-gitlab-auth@1.3.0"]
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin] const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
@@ -50,7 +50,7 @@ export namespace Plugin {
for (let plugin of plugins) { for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now // ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue if (plugin.includes("opencode-openai-codex-auth")) continue
log.info("loading plugin", { path: plugin }) log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) { if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@") const lastAtIndex = plugin.lastIndexOf("@")
-1
View File
@@ -47,7 +47,6 @@ export namespace ModelsDev {
.optional(), .optional(),
limit: z.object({ limit: z.object({
context: z.number(), context: z.number(),
input: z.number().optional(),
output: z.number(), output: z.number(),
}), }),
modalities: z modalities: z
+11 -23
View File
@@ -197,23 +197,16 @@ export namespace Provider {
return undefined return undefined
}) })
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE") if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false } const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
// Build credential provider options (only pass profile if specified)
const credentialProviderOptions = profile ? { profile } : {}
const providerOptions: AmazonBedrockProviderSettings = { const providerOptions: AmazonBedrockProviderSettings = {
region: defaultRegion, region: defaultRegion,
} credentialProvider: fromNodeProviderChain(credentialProviderOptions),
// Only use credential chain if no bearer token exists
// Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
if (!awsBearerToken) {
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
// Build credential provider options (only pass profile if specified)
const credentialProviderOptions = profile ? { profile } : {}
providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
} }
// Add custom endpoint if specified (endpoint takes precedence over baseURL) // Add custom endpoint if specified (endpoint takes precedence over baseURL)
@@ -399,7 +392,7 @@ export namespace Provider {
}, },
} }
}, },
gitlab: async (input) => { async gitlab(input) {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com" const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
const auth = await Auth.get(input.id) const auth = await Auth.get(input.id)
@@ -423,8 +416,10 @@ export namespace Provider {
...(providerConfig?.options?.featureFlags || {}), ...(providerConfig?.options?.featureFlags || {}),
}, },
}, },
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) { async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: { anthropicModel?: string }) {
const anthropicModel = options?.anthropicModel
return sdk.agenticChat(modelID, { return sdk.agenticChat(modelID, {
anthropicModel,
featureFlags: { featureFlags: {
duo_agent_platform_agentic_chat: true, duo_agent_platform_agentic_chat: true,
duo_agent_platform: true, duo_agent_platform: true,
@@ -557,7 +552,6 @@ export namespace Provider {
}), }),
limit: z.object({ limit: z.object({
context: z.number(), context: z.number(),
input: z.number().optional(),
output: z.number(), output: z.number(),
}), }),
status: z.enum(["alpha", "beta", "deprecated", "active"]), status: z.enum(["alpha", "beta", "deprecated", "active"]),
@@ -620,7 +614,6 @@ export namespace Provider {
}, },
limit: { limit: {
context: model.limit.context, context: model.limit.context,
input: model.limit.input,
output: model.limit.output, output: model.limit.output,
}, },
capabilities: { capabilities: {
@@ -872,12 +865,7 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
const data = database[providerID] const result = await fn(database[providerID])
if (!data) {
log.error("Provider does not exist in model list " + providerID)
continue
}
const result = await fn(data)
if (result && (result.autoload || providers[providerID])) { if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel if (result.getModel) modelLoaders[providerID] = result.getModel
mergeProvider(providerID, { mergeProvider(providerID, {
+24 -56
View File
@@ -16,31 +16,7 @@ function mimeToModality(mime: string): Modality | undefined {
} }
export namespace ProviderTransform { export namespace ProviderTransform {
function normalizeMessages( function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
msgs: ModelMessage[],
model: Provider.Model,
options: Record<string, unknown>,
): ModelMessage[] {
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" || options.store === false) {
msgs = msgs.map((msg) => {
if (!Array.isArray(msg.content)) return msg
const content = msg.content.map((part) => {
if (!part.providerOptions?.openai) return part
const { itemId, reasoningEncryptedContent, ...rest } = part.providerOptions.openai as Record<string, unknown>
const openai = Object.keys(rest).length > 0 ? rest : undefined
return {
...part,
providerOptions: {
...part.providerOptions,
openai,
},
}
})
return { ...msg, content } as typeof msg
})
}
// Anthropic rejects messages with empty content - filter out empty string messages // Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content // and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") { if (model.api.npm === "@ai-sdk/anthropic") {
@@ -242,9 +218,9 @@ export namespace ProviderTransform {
}) })
} }
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) { export function message(msgs: ModelMessage[], model: Provider.Model) {
msgs = unsupportedParts(msgs, model) msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options) msgs = normalizeMessages(msgs, model)
if ( if (
model.providerID === "anthropic" || model.providerID === "anthropic" ||
model.api.id.includes("anthropic") || model.api.id.includes("anthropic") ||
@@ -477,69 +453,64 @@ export namespace ProviderTransform {
return {} return {}
} }
export function options(input: { export function options(
model: Provider.Model model: Provider.Model,
sessionID: string sessionID: string,
providerOptions?: Record<string, any> providerOptions?: Record<string, any>,
}): Record<string, any> { ): Record<string, any> {
const result: Record<string, any> = {} const result: Record<string, any> = {}
// openai and providers using openai package should set store to false by default. if (model.api.npm === "@openrouter/ai-sdk-provider") {
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
result["store"] = false
}
if (input.model.api.npm === "@openrouter/ai-sdk-provider") {
result["usage"] = { result["usage"] = {
include: true, include: true,
} }
if (input.model.api.id.includes("gemini-3")) { if (model.api.id.includes("gemini-3")) {
result["reasoning"] = { effort: "high" } result["reasoning"] = { effort: "high" }
} }
} }
if ( if (
input.model.providerID === "baseten" || model.providerID === "baseten" ||
(input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id)) (model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(model.api.id))
) { ) {
result["chat_template_args"] = { enable_thinking: true } result["chat_template_args"] = { enable_thinking: true }
} }
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") { if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") {
result["thinking"] = { result["thinking"] = {
type: "enabled", type: "enabled",
clear_thinking: false, clear_thinking: false,
} }
} }
if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = input.sessionID result["promptCacheKey"] = sessionID
} }
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { if (model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex") {
result["thinkingConfig"] = { result["thinkingConfig"] = {
includeThoughts: true, includeThoughts: true,
} }
if (input.model.api.id.includes("gemini-3")) { if (model.api.id.includes("gemini-3")) {
result["thinkingConfig"]["thinkingLevel"] = "high" result["thinkingConfig"]["thinkingLevel"] = "high"
} }
} }
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { if (model.api.id.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) {
if (input.model.providerID.includes("codex")) { if (model.providerID.includes("codex")) {
result["store"] = false result["store"] = false
} }
if (!input.model.api.id.includes("codex") && !input.model.api.id.includes("gpt-5-pro")) { if (!model.api.id.includes("codex") && !model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium" result["reasoningEffort"] = "medium"
} }
if (input.model.api.id.endsWith("gpt-5.") && input.model.providerID !== "azure") { if (model.api.id.endsWith("gpt-5.") && model.providerID !== "azure") {
result["textVerbosity"] = "low" result["textVerbosity"] = "low"
} }
if (input.model.providerID.startsWith("opencode")) { if (model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = input.sessionID result["promptCacheKey"] = sessionID
result["include"] = ["reasoning.encrypted_content"] result["include"] = ["reasoning.encrypted_content"]
result["reasoningSummary"] = "auto" result["reasoningSummary"] = "auto"
} }
@@ -697,10 +668,7 @@ export namespace ProviderTransform {
export function error(providerID: string, error: APICallError) { export function error(providerID: string, error: APICallError) {
let message = error.message let message = error.message
if (providerID.includes("github-copilot") && error.statusCode === 403) { if (providerID === "github-copilot" && message.includes("The requested model is not supported")) {
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
}
if (providerID.includes("github-copilot") && message.includes("The requested model is not supported")) {
return ( return (
message + message +
"\n\nMake sure the model is enabled in your copilot settings: https://github.com/settings/copilot/features" "\n\nMake sure the model is enabled in your copilot settings: https://github.com/settings/copilot/features"
-4
View File
@@ -724,8 +724,6 @@ export namespace Server {
validator( validator(
"query", "query",
z.object({ z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce start: z.coerce
.number() .number()
.optional() .optional()
@@ -739,8 +737,6 @@ export namespace Server {
const term = query.search?.toLowerCase() const term = query.search?.toLowerCase()
const sessions: Session.Info[] = [] const sessions: Session.Info[] = []
for await (const session of Session.list()) { for await (const session of Session.list()) {
if (query.directory !== undefined && session.directory !== query.directory) continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
sessions.push(session) sessions.push(session)
+1 -1
View File
@@ -34,7 +34,7 @@ export namespace SessionCompaction {
if (context === 0) return false if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
const usable = input.model.limit.input || context - output const usable = context - output
return count > usable return count > usable
} }
+3 -6
View File
@@ -1,5 +1,5 @@
import { Slug } from "@opencode-ai/util/slug" import { Slug } from "@opencode-ai/util/slug"
import path from "path" import pat from "path"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
@@ -21,7 +21,7 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission/next"
import { Global } from "@/global" import path from "path"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -233,10 +233,7 @@ export namespace Session {
} }
export function plan(input: { slug: string; time: { created: number } }) { export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs return path.join(Instance.worktree, ".opencode", "plans", [input.time.created, input.slug].join("-") + ".md")
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
} }
export const get = fn(Identifier.schema("session"), async (id) => { export const get = fn(Identifier.schema("session"), async (id) => {
+3 -6
View File
@@ -95,11 +95,7 @@ export namespace LLM {
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small const base = input.small
? ProviderTransform.smallOptions(input.model) ? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({ : ProviderTransform.options(input.model, input.sessionID, provider.options)
model: input.model,
sessionID: input.sessionID,
providerOptions: provider.options,
})
const options: Record<string, any> = pipe( const options: Record<string, any> = pipe(
base, base,
mergeDeep(input.model.options), mergeDeep(input.model.options),
@@ -108,6 +104,7 @@ export namespace LLM {
) )
if (isCodex) { if (isCodex) {
options.instructions = SystemPrompt.instructions() options.instructions = SystemPrompt.instructions()
options.store = false
} }
const params = await Plugin.trigger( const params = await Plugin.trigger(
@@ -217,7 +214,7 @@ export namespace LLM {
async transformParams(args) { async transformParams(args) {
if (args.type === "stream") { if (args.type === "stream") {
// @ts-expect-error // @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) args.params.prompt = ProviderTransform.message(args.params.prompt, input.model)
} }
return args.params return args.params
}, },
+1 -3
View File
@@ -1229,13 +1229,11 @@ export namespace SessionPrompt {
messageID: userMessage.info.id, messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID, sessionID: userMessage.info.sessionID,
type: "text", type: "text",
text: text: BUILD_SWITCH.replace("{{plan}}", plan),
BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true, synthetic: true,
}) })
userMessage.parts.push(part) userMessage.parts.push(part)
} }
return input.messages
} }
// Entering plan mode // Entering plan mode
@@ -2,4 +2,6 @@
Your operational mode has changed from plan to build. Your operational mode has changed from plan to build.
You are no longer in read-only mode. You are no longer in read-only mode.
You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed. You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed.
A plan file exists at {{plan}}. You should read this file and execute on the plan defined within it.
</system-reminder> </system-reminder>
+6 -15
View File
@@ -1,5 +1,4 @@
import z from "zod" import z from "zod"
import path from "path"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
@@ -7,10 +6,8 @@ import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { exists } from "fs/promises"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Session } from "@/session"
export namespace Skill { export namespace Skill {
const log = Log.create({ service: "skill" }) const log = Log.create({ service: "skill" })
@@ -46,16 +43,10 @@ export namespace Skill {
const skills: Record<string, Info> = {} const skills: Record<string, Info> = {}
const addSkill = async (match: string) => { const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch((err) => { const md = await ConfigMarkdown.parse(match)
const message = ConfigMarkdown.FrontmatterError.isInstance(err) if (!md) {
? err.data.message return
: `Failed to parse skill ${match}` }
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return if (!parsed.success) return
@@ -86,7 +77,7 @@ export namespace Skill {
) )
// Also include global ~/.claude/skills/ // Also include global ~/.claude/skills/
const globalClaude = `${Global.Path.home}/.claude` const globalClaude = `${Global.Path.home}/.claude`
if (await Filesystem.isDir(globalClaude)) { if (await exists(globalClaude)) {
claudeDirs.push(globalClaude) claudeDirs.push(globalClaude)
} }
+2 -3
View File
@@ -2,7 +2,6 @@ import { Log } from "../util/log"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Global } from "../global" import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Lock } from "../util/lock" import { Lock } from "../util/lock"
import { $ } from "bun" import { $ } from "bun"
@@ -24,7 +23,7 @@ export namespace Storage {
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
async (dir) => { async (dir) => {
const project = path.resolve(dir, "../project") const project = path.resolve(dir, "../project")
if (!(await Filesystem.isDir(project))) return if (!fs.exists(project)) return
for await (const projectDir of new Bun.Glob("*").scan({ for await (const projectDir of new Bun.Glob("*").scan({
cwd: project, cwd: project,
onlyFiles: false, onlyFiles: false,
@@ -44,7 +43,7 @@ export namespace Storage {
if (worktree) break if (worktree) break
} }
if (!worktree) continue if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue if (!(await fs.exists(worktree))) continue
const [id] = await $`git rev-list --max-parents=0 --all` const [id] = await $`git rev-list --max-parents=0 --all`
.quiet() .quiet()
.nothrow() .nothrow()
+1 -1
View File
@@ -129,7 +129,7 @@ export const EditTool = Tool.define("edit", {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix = const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>` output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
} }
return { return {
+3 -21
View File
@@ -37,15 +37,7 @@ export const GrepTool = Tool.define("grep", {
await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath() const rgPath = await Ripgrep.filepath()
const args = [ const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
"-nH",
"--hidden",
"--follow",
"--no-messages",
"--field-match-separator=|",
"--regexp",
params.pattern,
]
if (params.include) { if (params.include) {
args.push("--glob", params.include) args.push("--glob", params.include)
} }
@@ -60,10 +52,7 @@ export const GrepTool = Tool.define("grep", {
const errorOutput = await new Response(proc.stderr).text() const errorOutput = await new Response(proc.stderr).text()
const exitCode = await proc.exited const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) if (exitCode === 1) {
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return { return {
title: params.pattern, title: params.pattern,
metadata: { matches: 0, truncated: false }, metadata: { matches: 0, truncated: false },
@@ -71,12 +60,10 @@ export const GrepTool = Tool.define("grep", {
} }
} }
if (exitCode !== 0 && exitCode !== 2) { if (exitCode !== 0) {
throw new Error(`ripgrep failed: ${errorOutput}`) throw new Error(`ripgrep failed: ${errorOutput}`)
} }
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings // Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/) const lines = output.trim().split(/\r?\n/)
const matches = [] const matches = []
@@ -137,11 +124,6 @@ export const GrepTool = Tool.define("grep", {
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
} }
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return { return {
title: params.pattern, title: params.pattern,
metadata: { metadata: {
+1 -1
View File
@@ -59,7 +59,7 @@ export const WriteTool = Tool.define("write", {
const suffix = const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
if (file === normalizedFilepath) { if (file === normalizedFilepath) {
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>` output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
continue continue
} }
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
+3 -13
View File
@@ -1,18 +1,8 @@
import { realpathSync } from "fs" import { realpathSync } from "fs"
import { exists } from "fs/promises"
import { dirname, join, relative } from "path" import { dirname, join, relative } from "path"
export namespace Filesystem { export namespace Filesystem {
export const exists = (p: string) =>
Bun.file(p)
.stat()
.then(() => true)
.catch(() => false)
export const isDir = (p: string) =>
Bun.file(p)
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
/** /**
* On Windows, normalize a path to its canonical casing using the filesystem. * On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers * This is needed because Windows paths are case-insensitive but LSP servers
@@ -41,7 +31,7 @@ export namespace Filesystem {
const result = [] const result = []
while (true) { while (true) {
const search = join(current, target) const search = join(current, target)
if (await exists(search)) result.push(search) if (await exists(search).catch(() => false)) result.push(search)
if (stop === current) break if (stop === current) break
const parent = dirname(current) const parent = dirname(current)
if (parent === current) break if (parent === current) break
@@ -56,7 +46,7 @@ export namespace Filesystem {
while (true) { while (true) {
for (const target of targets) { for (const target of targets) {
const search = join(current, target) const search = join(current, target)
if (await exists(search)) yield search if (await exists(search).catch(() => false)) yield search
} }
if (stop === current) break if (stop === current) break
const parent = dirname(current) const parent = dirname(current)
-20
View File
@@ -1,20 +0,0 @@
export function formatDuration(secs: number) {
if (secs <= 0) return ""
if (secs < 60) return `${secs}s`
if (secs < 3600) {
const mins = Math.floor(secs / 60)
const remaining = secs % 60
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
}
if (secs < 86400) {
const hours = Math.floor(secs / 3600)
const remaining = Math.floor((secs % 3600) / 60)
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
if (secs < 604800) {
const days = Math.floor(secs / 86400)
return days === 1 ? "~1 day" : `~${days} days`
}
const weeks = Math.floor(secs / 604800)
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
}
+3 -4
View File
@@ -10,8 +10,8 @@ export namespace Keybind {
leader: boolean // our custom field leader: boolean // our custom field
} }
export function match(a: Info | undefined, b: Info): boolean { export function match(a: Info, b: Info): boolean {
if (!a) return false // Normalize super field (undefined and false are equivalent)
const normalizedA = { ...a, super: a.super ?? false } const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false } const normalizedB = { ...b, super: b.super ?? false }
return isDeepEqual(normalizedA, normalizedB) return isDeepEqual(normalizedA, normalizedB)
@@ -32,8 +32,7 @@ export namespace Keybind {
} }
} }
export function toString(info: Info | undefined): string { export function toString(info: Info): string {
if (!info) return ""
const parts: string[] = [] const parts: string[] = []
if (info.ctrl) parts.push("ctrl") if (info.ctrl) parts.push("ctrl")
@@ -1,4 +0,0 @@
---
---
Content
@@ -1,28 +0,0 @@
---
description: "This is a description wrapped in quotes"
# field: this is a commented out field that should be ignored
occupation: This man has the following occupation: Software Engineer
title: 'Hello World'
name: John "Doe"
family: He has no 'family'
summary: >
This is a summary
url: https://example.com:8080/path?query=value
time: The time is 12:30:00 PM
nested: First: Second: Third: Fourth
quoted_colon: "Already quoted: no change needed"
single_quoted_colon: 'Single quoted: also fine'
mixed: He said "hello: world" and then left
empty:
dollar: Use $' and $& for special patterns
---
Content that should not be parsed:
fake_field: this is not yaml
another: neither is this
time: 10:30:00 AM
url: https://should-not-be-parsed.com:3000
The above lines look like YAML but are just content.
@@ -1 +0,0 @@
Content
+72 -175
View File
@@ -1,192 +1,89 @@
import { expect, test, describe } from "bun:test" import { expect, test } from "bun:test"
import { ConfigMarkdown } from "../../src/config/markdown" import { ConfigMarkdown } from "../../src/config/markdown"
describe("ConfigMarkdown: normal template", () => { const template = `This is a @valid/path/to/a/file and it should also match at
const template = `This is a @valid/path/to/a/file and it should also match at the beginning of a line:
the beginning of a line:
@another-valid/path/to/a/file @another-valid/path/to/a/file
but this is not: but this is not:
- Adds a "Co-authored-by:" footer which clarifies which AI agent - Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\` helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address. or \`noreply@anthropic.com\` email address.
We also need to deal with files followed by @commas, ones We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak, with @file-extensions.md, even @multiple.extensions.bak,
hidden directories like @.config/ or files like @.bashrc hidden directories like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md. and ones at the end of a sentence like @foo.md.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions, Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt. as well as @~/home-files and @~/paths/under/home.txt.
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.` If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
const matches = ConfigMarkdown.files(template) const matches = ConfigMarkdown.files(template)
test("should extract exactly 12 file references", () => { test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12) expect(matches.length).toBe(12)
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
})
}) })
describe("ConfigMarkdown: frontmatter parsing", async () => { test("should extract valid/path/to/a/file", () => {
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md") expect(matches[0][1]).toBe("valid/path/to/a/file")
test("should parse without throwing", () => {
expect(parsed).toBeDefined()
expect(parsed.data).toBeDefined()
expect(parsed.content).toBeDefined()
})
test("should extract description field", () => {
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
})
test("should extract occupation field with colon in value", () => {
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer\n")
})
test("should extract title field with single quotes", () => {
expect(parsed.data.title).toBe("Hello World")
})
test("should extract name field with embedded quotes", () => {
expect(parsed.data.name).toBe('John "Doe"')
})
test("should extract family field with embedded single quotes", () => {
expect(parsed.data.family).toBe("He has no 'family'")
})
test("should extract multiline summary field", () => {
expect(parsed.data.summary).toBe("This is a summary\n")
})
test("should not include commented fields in data", () => {
expect(parsed.data.field).toBeUndefined()
})
test("should extract URL with port", () => {
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value\n")
})
test("should extract time with colons", () => {
expect(parsed.data.time).toBe("The time is 12:30:00 PM\n")
})
test("should extract value with multiple colons", () => {
expect(parsed.data.nested).toBe("First: Second: Third: Fourth\n")
})
test("should preserve already double-quoted values with colons", () => {
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
})
test("should preserve already single-quoted values with colons", () => {
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
})
test("should extract value with quotes and colons mixed", () => {
expect(parsed.data.mixed).toBe('He said "hello: world" and then left\n')
})
test("should handle empty values", () => {
expect(parsed.data.empty).toBeNull()
})
test("should handle dollar sign replacement patterns literally", () => {
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
})
test("should not parse fake yaml from content", () => {
expect(parsed.data.fake_field).toBeUndefined()
expect(parsed.data.another).toBeUndefined()
})
test("should extract content after frontmatter without modification", () => {
expect(parsed.content).toContain("Content that should not be parsed:")
expect(parsed.content).toContain("fake_field: this is not yaml")
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
})
}) })
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => { test("should extract another-valid/path/to/a/file", () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md") expect(matches[1][1]).toBe("another-valid/path/to/a/file")
test("should parse without throwing", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
})
}) })
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => { test("should extract paths ignoring comma after", () => {
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md") expect(matches[2][1]).toBe("commas")
})
test("should parse without throwing", () => {
expect(result).toBeDefined() test("should extract a path with a file extension and comma after", () => {
expect(result.data).toEqual({}) expect(matches[3][1]).toBe("file-extensions.md")
expect(result.content.trim()).toBe("Content") })
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
}) })

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